UNPKG

filecoin-pin

Version:

Bridge IPFS content to Filecoin Onchain Cloud using familiar tools

305 lines (273 loc) 10.4 kB
import type { Synapse } from '@filoz/synapse-sdk' import type { CID } from 'multiformats/cid' import type { Logger } from 'pino' import { checkAllowances, checkFILBalance, checkUSDFCBalance, type PaymentCapacityCheck, setMaxAllowances, validatePaymentCapacity, validatePaymentRequirements, } from '../payments/index.js' import { isSessionKeyMode, type SynapseService } from '../synapse/index.js' import type { ProgressEvent, ProgressEventHandler } from '../utils/types.js' import { type ValidateIPNIProgressEvents, type WaitForIpniProviderResultsOptions, waitForIpniProviderResults, } from '../utils/validate-ipni-advertisement.js' import { type SynapseUploadResult, type UploadProgressEvents, uploadToSynapse } from './synapse.js' export type { SynapseUploadOptions, SynapseUploadResult, UploadProgressEvents } from './synapse.js' export { getDownloadURL, getServiceURL, uploadToSynapse } from './synapse.js' /** * Options for evaluating whether an upload can proceed. */ export type UploadReadinessProgressEvents = | ProgressEvent<'checking-balances'> | ProgressEvent<'checking-allowances'> | ProgressEvent<'configuring-allowances'> | ProgressEvent<'allowances-configured', { transactionHash?: string }> | ProgressEvent<'validating-capacity'> export interface UploadReadinessOptions { /** Initialized Synapse instance. */ synapse: Synapse /** Size of the CAR file (bytes). */ fileSize: number /** * Automatically configure allowances when they are missing. * Defaults to `true` to match current CLI/action behaviour. */ autoConfigureAllowances?: boolean /** Optional callback for progress updates. */ onProgress?: ProgressEventHandler<UploadReadinessProgressEvents> } /** * Result of the payment readiness check prior to upload. */ export interface UploadReadinessResult { /** Overall status of the readiness check. */ status: 'ready' | 'blocked' /** Gas + USDFC validation outcome. */ validation: { isValid: boolean errorMessage?: string helpMessage?: string } /** FIL/gas balance status. */ filStatus: Awaited<ReturnType<typeof checkFILBalance>> /** Wallet USDFC balance. */ walletUsdfcBalance: Awaited<ReturnType<typeof checkUSDFCBalance>> /** Allowance update information. */ allowances: { needsUpdate: boolean updated: boolean transactionHash?: string | undefined } /** Capacity check from Synapse (present even when blocked). */ capacity?: PaymentCapacityCheck /** Suggestions returned by the capacity check. */ suggestions: string[] } type CapacityStatus = 'sufficient' | 'warning' | 'insufficient' /** * Check readiness for uploading a CAR file. * * This performs the same validation chain previously used by the CLI/action: * 1. Ensure basic wallet requirements (FIL for gas, USDFC balance) * 2. Confirm or configure WarmStorage allowances * 3. Validate that the current deposit can cover the upload * * The function only mutates state when `autoConfigureAllowances` is enabled * (default), in which case it will call {@link setMaxAllowances} as needed. * * **Session Key Authentication**: When using session key authentication, * `autoConfigureAllowances` is automatically disabled since payment operations * require the owner wallet to sign. Allowances must be configured separately * by the owner wallet before uploads can proceed. */ export async function checkUploadReadiness(options: UploadReadinessOptions): Promise<UploadReadinessResult> { const { synapse, fileSize, autoConfigureAllowances = true, onProgress } = options // Detect session key mode - payment operations cannot be performed const sessionKeyMode = isSessionKeyMode(synapse) const canConfigureAllowances = autoConfigureAllowances && !sessionKeyMode onProgress?.({ type: 'checking-balances' }) const filStatus = await checkFILBalance(synapse) const walletUsdfcBalance = await checkUSDFCBalance(synapse) const validation = validatePaymentRequirements(filStatus.hasSufficientGas, walletUsdfcBalance, filStatus.isCalibnet) if (!validation.isValid) { return { status: 'blocked', validation, filStatus, walletUsdfcBalance, allowances: { needsUpdate: false, updated: false, }, suggestions: [], } } onProgress?.({ type: 'checking-allowances' }) const allowanceStatus = await checkAllowances(synapse) let allowancesUpdated = false let allowanceTxHash: string | undefined // Only try to configure allowances if not in session key mode if (allowanceStatus.needsUpdate && canConfigureAllowances) { onProgress?.({ type: 'configuring-allowances' }) const setResult = await setMaxAllowances(synapse) allowancesUpdated = true allowanceTxHash = setResult.transactionHash onProgress?.({ type: 'allowances-configured', data: { transactionHash: allowanceTxHash } }) } onProgress?.({ type: 'validating-capacity' }) const capacityCheck = await validatePaymentCapacity(synapse, fileSize) const capacityStatus = determineCapacityStatus(capacityCheck) if (capacityStatus === 'insufficient') { return { status: 'blocked', validation, filStatus, walletUsdfcBalance, allowances: { needsUpdate: allowanceStatus.needsUpdate, updated: allowancesUpdated, transactionHash: allowanceTxHash, }, capacity: capacityCheck, suggestions: capacityCheck.suggestions, } } return { status: 'ready', validation, filStatus, walletUsdfcBalance, allowances: { needsUpdate: allowanceStatus.needsUpdate, updated: allowancesUpdated, transactionHash: allowanceTxHash, }, capacity: capacityCheck, suggestions: capacityCheck.suggestions, } } function determineCapacityStatus(capacity: PaymentCapacityCheck): CapacityStatus { if (!capacity.canUpload) return 'insufficient' if (capacity.suggestions.length > 0) return 'warning' return 'sufficient' } export interface UploadExecutionOptions { /** Logger used for structured upload events. */ logger: Logger /** Optional identifier to help correlate logs. */ contextId?: string /** Optional umbrella onProgress receiving child progress events. */ onProgress?: ProgressEventHandler<(UploadProgressEvents | ValidateIPNIProgressEvents) & {}> /** Optional metadata to associate with the upload. */ pieceMetadata?: Record<string, string> /** * Optional IPNI validation behaviour. When enabled (default), the upload flow will wait for the IPFS Root CID to be announced to IPNI. */ ipniValidation?: { /** * Enable the IPNI validation wait. * * @default: true */ enabled?: boolean } & Omit<WaitForIpniProviderResultsOptions, 'onProgress'> } export interface UploadExecutionResult extends SynapseUploadResult { /** Active network derived from the Synapse instance. */ network: string /** Transaction hash from the piece-addition step (if available). */ transactionHash?: string | undefined /** * True if the IPFS Root CID was observed on filecoinpin.contact (IPNI). * * You should block any displaying, or attempting to access, of IPFS download URLs unless the IPNI validation is successful. */ ipniValidated: boolean } /** * Execute the upload to Synapse, returning the same structured data used by the * CLI and GitHub Action. */ export async function executeUpload( synapseService: SynapseService, carData: Uint8Array, rootCid: CID, options: UploadExecutionOptions ): Promise<UploadExecutionResult> { const { logger, contextId } = options let transactionHash: string | undefined let ipniValidationPromise: Promise<boolean> | undefined const onProgress: ProgressEventHandler<UploadProgressEvents | ValidateIPNIProgressEvents> = (event) => { switch (event.type) { case 'onPieceAdded': { // Begin IPNI validation as soon as the piece is added and parked in the data set if (options.ipniValidation?.enabled !== false && ipniValidationPromise == null) { const { enabled: _enabled, expectedProviders, ...restOptions } = options.ipniValidation ?? {} // Build validation options const validationOptions: WaitForIpniProviderResultsOptions = { ...restOptions, logger, } // Forward progress events to caller if they provided a handler if (options?.onProgress != null) { validationOptions.onProgress = options.onProgress } // Determine which providers to expect in IPNI // Priority: user-provided expectedProviders > current provider > none (generic validation) // Note: If expectedProviders is explicitly [], we respect that (no provider expectations) if (expectedProviders != null) { validationOptions.expectedProviders = expectedProviders } else if (synapseService.providerInfo != null) { validationOptions.expectedProviders = [synapseService.providerInfo] } // Start validation (runs in parallel with other operations) ipniValidationPromise = waitForIpniProviderResults(rootCid, validationOptions).catch((error) => { logger.warn({ error }, 'IPNI provider results check was rejected') return false }) } if (event.data.txHash != null) { transactionHash = event.data.txHash } break } default: { break } } options.onProgress?.(event) } const uploadOptions: Parameters<typeof uploadToSynapse>[4] = { onProgress, } if (contextId) { uploadOptions.contextId = contextId } if (options.pieceMetadata) { uploadOptions.pieceMetadata = options.pieceMetadata } const uploadResult = await uploadToSynapse(synapseService, carData, rootCid, logger, uploadOptions) // Optionally validate IPNI advertisement of the root CID before returning let ipniValidated = false if (ipniValidationPromise != null) { try { ipniValidated = await ipniValidationPromise } catch (error) { logger.error({ error }, 'Could not validate IPNI provider records') ipniValidated = false } } const result: UploadExecutionResult = { ...uploadResult, network: synapseService.synapse.getNetwork(), transactionHash, ipniValidated, } return result }