filecoin-pin
Version:
Bridge IPFS content to Filecoin Onchain Cloud using familiar tools
466 lines • 19.5 kB
JavaScript
import { ADD_PIECES_TYPEHASH, CREATE_DATA_SET_TYPEHASH, RPC_URLS, Synapse, } from '@filoz/synapse-sdk';
import { JsonRpcProvider, Wallet, WebSocketProvider } from 'ethers';
import { ADDRESS_ONLY_SIGNER_SYMBOL, AddressOnlySigner } from './address-only-signer.js';
import { DEFAULT_DATA_SET_METADATA, DEFAULT_STORAGE_CONTEXT_CONFIG } from './constants.js';
import { getTelemetryConfig } from './telemetry-config.js';
export * from './constants.js';
const WEBSOCKET_REGEX = /^ws(s)?:\/\//i;
let synapseInstance = null;
let storageInstance = null;
let currentProviderInfo = null;
let activeProvider = null; // Track the provider for cleanup
/**
* Reset the service instances (for testing)
*/
export function resetSynapseService() {
synapseInstance = null;
storageInstance = null;
currentProviderInfo = null;
activeProvider = null;
}
/**
* Check if Synapse is using session key authentication
*
* Session key authentication uses an AddressOnlySigner which cannot sign transactions.
* Payment operations (deposits, allowances) must be done by the owner wallet separately.
*
* Uses a Symbol to reliably detect AddressOnlySigner even across module boundaries.
*
* @param synapse - Initialized Synapse instance
* @returns true if using session key authentication, false otherwise
*/
export function isSessionKeyMode(synapse) {
try {
const client = synapse.getClient();
// The client might be wrapped in a NonceManager, check the underlying signer
let signerToCheck = client;
if ('signer' in client && client.signer) {
signerToCheck = client.signer;
}
// Check for the AddressOnlySigner symbol (most reliable)
return ADDRESS_ONLY_SIGNER_SYMBOL in signerToCheck && signerToCheck[ADDRESS_ONLY_SIGNER_SYMBOL] === true;
}
catch {
return false;
}
}
/**
* Type guards for authentication configuration
*/
function isPrivateKeyConfig(config) {
return 'privateKey' in config && config.privateKey != null;
}
function isSessionKeyConfig(config) {
return ('walletAddress' in config && 'sessionKey' in config && config.walletAddress != null && config.sessionKey != null);
}
function isSignerConfig(config) {
return 'signer' in config && config.signer != null;
}
/**
* Validate authentication configuration
*/
function validateAuthConfig(config) {
const hasPrivateKey = isPrivateKeyConfig(config);
const hasSessionKey = isSessionKeyConfig(config);
const hasSigner = isSignerConfig(config);
const authCount = [hasPrivateKey, hasSessionKey, hasSigner].filter(Boolean).length;
if (authCount === 0) {
throw new Error('Authentication required: provide either privateKey, walletAddress + sessionKey, or signer');
}
if (authCount > 1) {
throw new Error('Conflicting authentication: provide only one of privateKey, walletAddress + sessionKey, or signer');
}
if (hasPrivateKey)
return 'standard';
if (hasSessionKey)
return 'session-key';
return 'signer';
}
/**
* Create ethers provider for the given RPC URL
*/
function createProvider(rpcURL) {
if (WEBSOCKET_REGEX.test(rpcURL)) {
return new WebSocketProvider(rpcURL);
}
return new JsonRpcProvider(rpcURL);
}
/**
* Setup and verify session key, throws if expired
*/
async function setupSessionKey(synapse, sessionWallet, logger) {
const sessionKey = synapse.createSessionKey(sessionWallet);
// Verify permissions - fail fast if expired or expiring soon
const expiries = await sessionKey.fetchExpiries([CREATE_DATA_SET_TYPEHASH, ADD_PIECES_TYPEHASH]);
const now = Math.floor(Date.now() / 1000);
const bufferTime = 30 * 60; // 30 minutes in seconds
const minValidTime = now + bufferTime;
const createDataSetExpiry = Number(expiries[CREATE_DATA_SET_TYPEHASH]);
const addPiecesExpiry = Number(expiries[ADD_PIECES_TYPEHASH]);
// For CREATE_DATA_SET:
// - 0 means no permission granted (OK - can still add to existing datasets)
// - > 0 but < minValidTime means expired/expiring (ERROR)
// - >= minValidTime means valid (OK)
const hasCreateDataSetPermission = createDataSetExpiry > 0;
const isCreateDataSetPermissionUnavailable = hasCreateDataSetPermission && createDataSetExpiry < minValidTime;
// For ADD_PIECES:
// - Must always have valid permission
const isAddPiecesPermissionUnavailable = addPiecesExpiry <= minValidTime;
if (isCreateDataSetPermissionUnavailable) {
throw new Error(`Session key expired or expiring soon (requires 30+ minutes validity). CreateDataSet: ${new Date(createDataSetExpiry * 1000).toISOString()}`);
}
if (isAddPiecesPermissionUnavailable) {
throw new Error(`Session key expired or expiring soon (requires 30+ minutes validity). AddPieces: ${new Date(addPiecesExpiry * 1000).toISOString()}`);
}
if (!hasCreateDataSetPermission) {
logger.info({ event: 'synapse.session_key.limited_permissions' }, 'Session key can only add pieces to existing datasets (no CREATE_DATA_SET permission)');
}
logger.info({ event: 'synapse.session_key.verified', createExpiry: createDataSetExpiry, addExpiry: addPiecesExpiry }, 'Session key verified');
synapse.setSession(sessionKey);
logger.info({ event: 'synapse.session_key.activated' }, 'Session key activated');
}
/**
* Initialize the Synapse SDK without creating storage context
*
* Supports three authentication modes:
* - Standard: privateKey only
* - Session Key: walletAddress + sessionKey
* - Signer: ethers Signer instance
*
* @param config - Application configuration with authentication credentials
* @param logger - Logger instance for detailed operation tracking
* @returns Initialized Synapse instance
*/
export async function initializeSynapse(config, logger) {
const { withCDN, warmStorageAddress, telemetry, ...restConfig } = config;
try {
const authMode = validateAuthConfig(config);
// Determine RPC URL based on auth mode
let rpcURL;
if (isSignerConfig(config)) {
rpcURL = config.rpcUrl ?? RPC_URLS[config.network].websocket;
}
else {
rpcURL = config.rpcUrl ?? RPC_URLS.calibration.websocket;
}
logger.info({ event: 'synapse.init', authMode, rpcUrl: rpcURL }, 'Initializing Synapse SDK');
const synapseOptions = {
...restConfig,
rpcURL,
withIpni: true, // Always filter for IPNI-enabled providers
};
if (withCDN) {
synapseOptions.withCDN = true;
}
if (warmStorageAddress) {
synapseOptions.warmStorageAddress = warmStorageAddress;
}
synapseOptions.telemetry = getTelemetryConfig(telemetry);
let synapse;
if (authMode === 'session-key') {
// Session key mode - type guard ensures these are defined
if (!isSessionKeyConfig(config)) {
throw new Error('Internal error: session key mode but config type mismatch');
}
// Create provider and signers for session key mode
const provider = createProvider(rpcURL);
activeProvider = provider;
const ownerSigner = new AddressOnlySigner(config.walletAddress, provider);
const sessionWallet = new Wallet(config.sessionKey, provider);
// Initialize with owner signer, then activate session key
synapse = await Synapse.create({
...synapseOptions,
signer: ownerSigner,
});
await setupSessionKey(synapse, sessionWallet, logger);
}
else if (authMode === 'signer') {
// Signer mode - type guard ensures signer is defined
if (!isSignerConfig(config)) {
throw new Error('Internal error: signer mode but config type mismatch');
}
synapse = await Synapse.create({ ...synapseOptions, signer: config.signer });
activeProvider = synapse.getProvider();
}
else {
// Private key mode - type guard ensures privateKey is defined
if (!isPrivateKeyConfig(config)) {
throw new Error('Internal error: private key mode but config type mismatch');
}
synapse = await Synapse.create({ ...synapseOptions, privateKey: config.privateKey });
activeProvider = synapse.getProvider();
}
const network = synapse.getNetwork();
logger.info({ event: 'synapse.init.success', network }, 'Synapse SDK initialized');
synapseInstance = synapse;
return synapse;
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error({ event: 'synapse.init.failed', error: errorMessage }, 'Failed to initialize Synapse SDK');
throw error;
}
}
/**
* Create storage context for an initialized Synapse instance
*
* This creates a storage context with comprehensive callbacks for tracking
* the data set creation and provider selection process. This is primarily
* a wrapper around the Synapse SDK's storage context creation, adding logging
* and progress callbacks for better observability.
*
* @param synapse - Initialized Synapse instance
* @param logger - Logger instance for detailed operation tracking
* @param options - Optional configuration for dataset selection and callbacks
* @returns Storage context and provider information
*
* @example
* ```typescript
* // Create a new dataset (multi-user scenario)
* const { storage } = await createStorageContext(synapse, {
* logger,
* dataset: { createNew: true }
* })
*
* // Connect to existing dataset
* const { storage } = await createStorageContext(synapse, {
* logger,
* dataset: { useExisting: 123 }
* })
*
* // Default behavior (reuse wallet's dataset)
* const { storage } = await createStorageContext(synapse, { logger })
* ```
*/
export async function createStorageContext(synapse, options) {
const logger = options?.logger;
try {
// Create storage context with comprehensive event tracking
// The storage context manages the data set and provider interactions
logger?.info?.({ event: 'synapse.storage.create' }, 'Creating storage context');
// Convert our curated options to Synapse SDK options
const sdkOptions = {
...DEFAULT_STORAGE_CONTEXT_CONFIG,
};
// Apply dataset options
if (options?.dataset?.useExisting != null) {
sdkOptions.dataSetId = options.dataset.useExisting;
logger?.info?.({ event: 'synapse.storage.dataset.existing', dataSetId: options.dataset.useExisting }, 'Connecting to existing dataset');
}
else if (options?.dataset?.createNew === true) {
// If explicitly creating a new dataset in session key mode, verify we have permission
if (isSessionKeyMode(synapse)) {
const signer = synapse.getSigner();
const sessionKey = synapse.createSessionKey(signer);
const expiries = await sessionKey.fetchExpiries([CREATE_DATA_SET_TYPEHASH]);
const createDataSetExpiry = Number(expiries[CREATE_DATA_SET_TYPEHASH]);
if (createDataSetExpiry === 0) {
throw new Error('Cannot create new dataset: Session key does not have CREATE_DATA_SET permission. ' +
'Either use an existing dataset or obtain a session key with dataset creation rights.');
}
}
sdkOptions.forceCreateDataSet = true;
logger?.info?.({ event: 'synapse.storage.dataset.create_new' }, 'Forcing creation of new dataset');
}
// Merge metadata (dataset metadata takes precedence)
sdkOptions.metadata = {
...DEFAULT_DATA_SET_METADATA,
...options?.dataset?.metadata,
};
/**
* Callbacks provide visibility into the storage lifecycle
* These are crucial for debugging and monitoring in production
*/
const callbacks = {
onProviderSelected: (provider) => {
currentProviderInfo = provider;
logger?.info?.({
event: 'synapse.storage.provider_selected',
provider: {
id: provider.id,
serviceProvider: provider.serviceProvider,
name: provider.name,
serviceURL: provider.products?.PDP?.data?.serviceURL,
},
}, 'Selected storage provider');
options?.callbacks?.onProviderSelected?.(provider);
},
onDataSetResolved: (info) => {
logger?.info?.({
event: 'synapse.storage.data_set_resolved',
dataSetId: info.dataSetId,
isExisting: info.isExisting,
}, info.isExisting ? 'Using existing data set' : 'Created new data set');
options?.callbacks?.onDataSetResolved?.(info);
},
};
sdkOptions.callbacks = callbacks;
// Apply provider override if present
if (options?.providerAddress) {
sdkOptions.providerAddress = options.providerAddress;
logger?.info?.({ event: 'synapse.storage.provider_override', providerAddress: options.providerAddress }, 'Overriding provider by address');
}
else if (options?.providerId != null && Number.isFinite(options.providerId)) {
sdkOptions.providerId = options.providerId;
logger?.info?.({ event: 'synapse.storage.provider_override', providerId: options.providerId }, 'Overriding provider by ID');
}
const storage = await synapse.storage.createContext(sdkOptions);
logger?.info?.({
event: 'synapse.storage.created',
dataSetId: storage.dataSetId,
serviceProvider: storage.serviceProvider,
}, 'Storage context created successfully');
// Store instance
storageInstance = storage;
// Ensure we always have provider info
if (!currentProviderInfo) {
// This should not happen as provider is selected during context creation
throw new Error('Provider information not available after storage context creation');
}
return { storage, providerInfo: currentProviderInfo };
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger?.error?.({
event: 'synapse.storage.create.failed',
error: errorMessage,
}, `Failed to create storage context: ${errorMessage}`);
throw error;
}
}
/**
* Set up complete Synapse service with SDK and storage context
*
* This function demonstrates the complete setup flow for Synapse:
* 1. Validates required configuration (private key)
* 2. Creates Synapse instance with network configuration
* 3. Creates a storage context with comprehensive callbacks
* 4. Returns a service object for application use
*
* Our wrapping of Synapse initialization and storage context creation is
* primarily to handle our custom configuration needs and add detailed logging
* and progress tracking.
*
* @param config - Application configuration with privateKey and RPC URL
* @param logger - Logger instance for detailed operation tracking
* @param options - Optional dataset selection and callbacks
* @returns SynapseService with initialized Synapse and storage context
*
* @example
* ```typescript
* // Standard setup (reuses wallet's dataset)
* const service = await setupSynapse(config, logger)
*
* // Create new dataset for multi-user scenario
* const service = await setupSynapse(config, logger, {
* dataset: { createNew: true }
* })
*
* // Connect to specific dataset
* const service = await setupSynapse(config, logger, {
* dataset: { useExisting: 123 }
* })
* ```
*/
export async function setupSynapse(config, logger, options) {
// Initialize SDK
const synapse = await initializeSynapse(config, logger);
// Create storage context
let storageOptions = options ? { ...options } : undefined;
if (config.dataSetMetadata && Object.keys(config.dataSetMetadata).length > 0) {
storageOptions = {
...(storageOptions ?? {}),
dataset: {
...(storageOptions?.dataset ?? {}),
metadata: {
...config.dataSetMetadata,
...(storageOptions?.dataset?.metadata ?? {}),
},
},
};
}
const { storage, providerInfo } = await createStorageContext(synapse, {
...(storageOptions ?? {}),
logger,
});
return { synapse, storage, providerInfo };
}
/**
* Get default storage context configuration for consistent data set creation
*
* @param overrides - Optional overrides to merge with defaults
* @returns Storage context configuration with defaults
*/
export function getDefaultStorageContextConfig(overrides = {}) {
return {
...DEFAULT_STORAGE_CONTEXT_CONFIG,
...overrides,
metadata: {
...DEFAULT_DATA_SET_METADATA,
...overrides.metadata,
},
};
}
/**
* Clean up a WebSocket provider connection
* This is important for allowing the Node.js process to exit cleanly
*
* @param provider - The provider to clean up
*/
export async function cleanupProvider(provider) {
if (provider && typeof provider.destroy === 'function') {
// Suppress all errors during cleanup
// WebSocket providers can throw async errors from scheduled operations
// (like eth_unsubscribe) after destroy() is called
const errorHandler = () => {
// Silently ignore all cleanup errors
};
// Add error listener to suppress errors from async operations
if (typeof provider.on === 'function') {
provider.on('error', errorHandler);
}
try {
await provider.destroy();
}
catch {
// Ignore cleanup errors
}
// Small delay to allow any pending async operations to complete
// This prevents errors from scheduled operations that trigger after destroy()
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
/**
* Clean up WebSocket providers and other resources
*
* Call this when CLI commands are finishing to ensure proper cleanup
* and allow the process to terminate
*/
export async function cleanupSynapseService() {
// Close telemetry to flush pending events and shutdown cleanly
if (synapseInstance) {
await synapseInstance.telemetry?.sentry?.close();
}
if (activeProvider) {
await cleanupProvider(activeProvider);
}
// Clear references
synapseInstance = null;
storageInstance = null;
currentProviderInfo = null;
activeProvider = null;
}
/**
* Get the initialized Synapse service
*/
export function getSynapseService() {
if (synapseInstance == null || storageInstance == null || currentProviderInfo == null) {
return null;
}
return {
synapse: synapseInstance,
storage: storageInstance,
providerInfo: currentProviderInfo,
};
}
//# sourceMappingURL=index.js.map