UNPKG

filecoin-pin

Version:

Bridge IPFS content to Filecoin Onchain Cloud using familiar tools

735 lines (648 loc) 23.4 kB
import { ADD_PIECES_TYPEHASH, CREATE_DATA_SET_TYPEHASH, type ProviderInfo, RPC_URLS, type StorageContext, type StorageContextCallbacks, type StorageServiceOptions, Synapse, type SynapseOptions, type TelemetryConfig, } from '@filoz/synapse-sdk' import { type Provider as EthersProvider, JsonRpcProvider, type Signer, Wallet, WebSocketProvider } from 'ethers' import type { Logger } from 'pino' 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: Synapse | null = null let storageInstance: StorageContext | null = null let currentProviderInfo: ProviderInfo | null = null let activeProvider: any = null // Track the provider for cleanup /** * Complete application configuration interface * This is the main config interface that can be imported by CLI and other consumers */ export interface Config { port: number host: string privateKey: string | undefined rpcUrl: string databasePath: string // TODO: remove this from core? carStoragePath: string logLevel: string warmStorageAddress: string | undefined } /** * Common options for all Synapse configurations */ interface BaseSynapseConfig extends Omit<SynapseOptions, 'withCDN' | 'warmStorageAddress' | 'telemetry'> { /** RPC endpoint for the target Filecoin network. Defaults to calibration. */ rpcUrl?: string | undefined /** Optional override for WarmStorage contract address */ warmStorageAddress?: string | undefined withCDN?: boolean | undefined /** Default metadata to apply when creating or reusing datasets */ dataSetMetadata?: Record<string, string> /** * Telemetry configuration. * Defaults to enabled unless explicitly disabled. * @example * { * sentryInitOptions: { * enabled: false, // if want to disable telemetry. * }, * sentrySetTags: { * appName: "${your-app-name}", * }, * } */ telemetry?: TelemetryConfig } /** * Standard authentication with private key */ export interface PrivateKeyConfig extends BaseSynapseConfig { privateKey: string } /** * Session key authentication with wallet address and session key */ export interface SessionKeyConfig extends BaseSynapseConfig { walletAddress: string sessionKey: string } /** * Signer-based authentication with ethers Signer */ export interface SignerConfig extends BaseSynapseConfig { signer: Signer /** Target Filecoin network (required for signer mode to determine default RPC) */ network: 'mainnet' | 'calibration' } /** * Configuration for Synapse initialization * * Supports three authentication modes: * 1. Standard: privateKey only * 2. Session Key: walletAddress + sessionKey * 3. Signer: ethers Signer instance */ export type SynapseSetupConfig = PrivateKeyConfig | SessionKeyConfig | SignerConfig /** * Structured service object containing the fully initialized Synapse SDK and * its storage context */ export interface SynapseService { synapse: Synapse storage: StorageContext providerInfo: ProviderInfo } /** * Dataset selection options for multi-tenant scenarios. * * This is a curated subset of Synapse SDK options focused on the common * use cases for filecoin-pin. */ export interface DatasetOptions { /** * Create a new dataset even if one exists for this wallet. * * Set to `true` when you want each user to have their own dataset * despite sharing the same wallet (e.g., multi-tenant websites and org/enterprise services using the same wallet). * * @default false */ createNew?: boolean /** * Connect to a specific dataset by ID. * * Use this to reconnect to a user's existing dataset after retrieving * the ID from localStorage or a database. * * Takes precedence over `createNew` if both are provided. */ useExisting?: number /** * Custom metadata to attach to the dataset. * * Note: If `useExisting` is provided, metadata is ignored since you're * connecting to an existing dataset. */ metadata?: Record<string, string> } /** * Options for creating a storage context. */ export interface CreateStorageContextOptions { /** * Dataset selection options. */ dataset?: DatasetOptions /** * Progress callbacks for tracking creation. */ callbacks?: StorageContextCallbacks /** * Override provider selection by address. * Takes precedence over providerId if both are specified. */ providerAddress?: string /** * Override provider selection by ID. */ providerId?: number /** * Optional logger instance for detailed operation tracking and progress callbacks. * If not provided, logging will be skipped. */ logger?: Partial<Logger> | undefined } /** * Reset the service instances (for testing) */ export function resetSynapseService(): void { 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: Synapse): boolean { try { const client = synapse.getClient() // The client might be wrapped in a NonceManager, check the underlying signer let signerToCheck: any = 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: Partial<SynapseSetupConfig>): config is PrivateKeyConfig { return 'privateKey' in config && config.privateKey != null } function isSessionKeyConfig(config: Partial<SynapseSetupConfig>): config is SessionKeyConfig { return ( 'walletAddress' in config && 'sessionKey' in config && config.walletAddress != null && config.sessionKey != null ) } function isSignerConfig(config: Partial<SynapseSetupConfig>): config is SignerConfig { return 'signer' in config && config.signer != null } /** * Validate authentication configuration */ function validateAuthConfig(config: Partial<SynapseSetupConfig>): 'standard' | 'session-key' | 'signer' { 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: string): EthersProvider { 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: Synapse, sessionWallet: Wallet, logger: Logger): Promise<void> { 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: Partial<SynapseSetupConfig>, logger: Logger): Promise<Synapse> { const { withCDN, warmStorageAddress, telemetry, ...restConfig } = config try { const authMode = validateAuthConfig(config) // Determine RPC URL based on auth mode let rpcURL: string 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: 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: 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: Synapse, options?: CreateStorageContextOptions ): Promise<{ storage: StorageContext; providerInfo: ProviderInfo }> { 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: StorageServiceOptions = { ...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: StorageContextCallbacks = { 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: SynapseSetupConfig, logger: Logger, options?: CreateStorageContextOptions ): Promise<SynapseService> { // 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: any = {}) { 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: any): Promise<void> { 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(): Promise<void> { // 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(): SynapseService | null { if (synapseInstance == null || storageInstance == null || currentProviderInfo == null) { return null } return { synapse: synapseInstance, storage: storageInstance, providerInfo: currentProviderInfo, } }