filecoin-pin
Version:
Bridge IPFS content to Filecoin Onchain Cloud using familiar tools
426 lines (387 loc) • 14.1 kB
text/typescript
/**
* Common upload flow shared between import and add commands
*
* This module provides reusable functions for the Synapse upload workflow
* including payment validation, storage context creation, and result display.
*/
import type { PieceCID, Synapse } from '@filoz/synapse-sdk'
import type { CID } from 'multiformats/cid'
import pc from 'picocolors'
import type { Logger } from 'pino'
import { DEFAULT_LOCKUP_DAYS, type PaymentCapacityCheck } from '../core/payments/index.js'
import { cleanupSynapseService, type SynapseService } from '../core/synapse/index.js'
import {
checkUploadReadiness,
executeUpload,
getDownloadURL,
getServiceURL,
type SynapseUploadResult,
} from '../core/upload/index.js'
import { formatUSDFC } from '../core/utils/format.js'
import { autoFund } from '../payments/fund.js'
import type { AutoFundOptions } from '../payments/types.js'
import type { Spinner } from '../utils/cli-helpers.js'
import { cancel, formatFileSize } from '../utils/cli-helpers.js'
import { log } from '../utils/cli-logger.js'
import { createSpinnerFlow } from '../utils/multi-operation-spinner.js'
export interface UploadFlowOptions {
/**
* Context identifier for logging (e.g., 'import', 'add')
*/
contextType: string
/**
* Size of the file being uploaded in bytes
*/
fileSize: number
/**
* Logger instance
*/
logger: Logger
/**
* Optional spinner for progress updates
*/
spinner?: Spinner
/**
* Optional metadata attached to the upload request
*/
pieceMetadata?: Record<string, string>
}
export interface UploadFlowResult extends SynapseUploadResult {
network: string
transactionHash?: string | undefined
}
/**
* Perform auto-funding if requested
* Automatically ensures a minimum of 30 days of runway based on current usage + new file requirements
*
* @param synapse - Initialized Synapse instance
* @param fileSize - Size of file being uploaded (in bytes)
* @param spinner - Optional spinner for progress
*/
export async function performAutoFunding(synapse: Synapse, fileSize: number, spinner?: Spinner): Promise<void> {
spinner?.start('Checking funding requirements for upload...')
try {
const fundOptions: AutoFundOptions = {
synapse,
fileSize,
}
if (spinner !== undefined) {
fundOptions.spinner = spinner
}
const result = await autoFund(fundOptions)
spinner?.stop(`${pc.green('✓')} Funding requirements met`)
if (result.adjusted) {
log.line('')
log.line(pc.bold('Auto-funding completed:'))
log.indent(`Deposited ${formatUSDFC(result.delta)} USDFC`)
log.indent(`Total deposited: ${formatUSDFC(result.newDepositedAmount)} USDFC`)
log.indent(
`Runway: ~${result.newRunwayDays} day(s)${result.newRunwayHours > 0 ? ` ${result.newRunwayHours} hour(s)` : ''}`
)
if (result.transactionHash) {
log.indent(pc.gray(`Transaction: ${result.transactionHash}`))
}
log.line('')
log.flush()
}
} catch (error) {
spinner?.stop(`${pc.red('✗')} Auto-funding failed`)
log.line('')
log.line(`${pc.red('Error:')} ${error instanceof Error ? error.message : String(error)}`)
log.flush()
await cleanupSynapseService()
cancel('Operation cancelled - auto-funding failed')
process.exit(1)
}
}
/**
* Validate payment setup and capacity for upload
*
* @param synapse - Initialized Synapse instance
* @param fileSize - Size of file to upload in bytes (use 0 for minimum setup check)
* @param spinner - Optional spinner for progress
* @param options - Optional configuration
* @param options.suppressSuggestions - If true, don't display suggestion warnings
* @returns true if validation passes, exits process if not
*/
export async function validatePaymentSetup(
synapse: Synapse,
fileSize: number,
spinner?: Spinner,
options?: { suppressSuggestions?: boolean }
): Promise<void> {
const readiness = await checkUploadReadiness({
synapse,
fileSize,
onProgress: (event) => {
if (!spinner) return
switch (event.type) {
case 'checking-balances': {
spinner.message('Checking payment setup requirements...')
return
}
case 'checking-allowances': {
spinner.message('Checking WarmStorage permissions...')
return
}
case 'configuring-allowances': {
spinner.message('Configuring WarmStorage permissions (one-time setup)...')
return
}
case 'validating-capacity': {
spinner.message('Validating payment capacity...')
return
}
case 'allowances-configured': {
// No spinner change; we log once readiness completes.
return
}
}
},
})
const { validation, allowances, capacity, suggestions } = readiness
if (!validation.isValid) {
spinner?.stop(`${pc.red('✗')} Payment setup incomplete`)
log.line('')
log.line(`${pc.red('✗')} ${validation.errorMessage}`)
if (validation.helpMessage) {
log.line('')
log.line(` ${pc.cyan(validation.helpMessage)}`)
}
log.line('')
log.line(`${pc.yellow('⚠')} Your payment setup is not complete. Please run:`)
log.indent(pc.cyan('filecoin-pin payments setup'))
log.line('')
log.line('For more information, run:')
log.indent(pc.cyan('filecoin-pin payments status'))
log.flush()
await cleanupSynapseService()
cancel('Operation cancelled - payment setup required')
process.exit(1)
}
if (allowances.updated) {
spinner?.stop(`${pc.green('✓')} WarmStorage permissions configured`)
if (allowances.transactionHash) {
log.indent(pc.gray(`Transaction: ${allowances.transactionHash}`))
log.flush()
}
spinner?.start('Validating payment capacity...')
} else {
spinner?.message('Validating payment capacity...')
}
if (!capacity?.canUpload) {
if (capacity) {
displayPaymentIssues(capacity, fileSize, spinner)
}
await cleanupSynapseService()
cancel('Operation cancelled - insufficient payment capacity')
process.exit(1)
}
// Show warning if suggestions exist (even if upload is possible)
if (suggestions.length > 0 && capacity?.canUpload && !options?.suppressSuggestions) {
spinner?.stop(`${pc.yellow('⚠')} Payment capacity check passed with warnings`)
log.line(pc.bold('Suggestions:'))
suggestions.forEach((suggestion) => {
log.indent(`• ${suggestion}`)
})
log.flush()
} else if (fileSize === 0) {
// Different message based on whether this is minimum setup (fileSize=0) or actual capacity check
// Note: 0.06 USDFC is the floor price, but with 10% buffer, ~0.066 USDFC is actually required
spinner?.stop(`${pc.green('✓')} Minimum payment setup verified (~0.066 USDFC required)`)
} else {
spinner?.stop(`${pc.green('✓')} Payment capacity verified for ${formatFileSize(fileSize)}`)
}
}
/**
* Display payment capacity issues and suggestions
*/
function displayPaymentIssues(capacityCheck: PaymentCapacityCheck, fileSize: number, spinner?: Spinner): void {
spinner?.stop(`${pc.red('✗')} Insufficient deposit for this file`)
log.line(pc.bold('File Requirements:'))
if (fileSize === 0) {
log.indent(`File size: ${formatFileSize(fileSize)} (${capacityCheck.storageTiB.toFixed(4)} TiB)`)
}
log.indent(`Storage cost: ${formatUSDFC(capacityCheck.required.rateAllowance)} USDFC/epoch`)
log.indent(
`Required deposit: ${formatUSDFC(capacityCheck.required.lockupAllowance + capacityCheck.required.lockupAllowance / 10n)} USDFC ${pc.gray(`(includes ${DEFAULT_LOCKUP_DAYS}-day safety reserve)`)}`
)
log.line('')
log.line(pc.bold('Suggested actions:'))
capacityCheck.suggestions.forEach((suggestion: string) => {
log.indent(`• ${suggestion}`)
})
log.line('')
// Calculate suggested deposit
const suggestedDeposit = capacityCheck.issues.insufficientDeposit
? formatUSDFC(capacityCheck.issues.insufficientDeposit)
: '0'
log.line(`${pc.yellow('⚠')} To fix this, run:`)
log.indent(pc.cyan(`filecoin-pin payments setup --deposit ${suggestedDeposit} --auto`))
log.flush()
}
/**
* Upload CAR data to Synapse with progress tracking
*
* @param synapseService - Initialized Synapse service with storage context
* @param carData - CAR file data as Uint8Array
* @param rootCid - Root CID of the content
* @param options - Upload flow options
* @returns Upload result with transaction hash
*/
export async function performUpload(
synapseService: SynapseService,
carData: Uint8Array,
rootCid: CID,
options: UploadFlowOptions
): Promise<UploadFlowResult> {
const { contextType, logger, spinner, pieceMetadata } = options
// Create spinner flow manager for tracking all operations
const flow = createSpinnerFlow(spinner)
// Start with upload operation
flow.addOperation('upload', 'Uploading to Filecoin...')
let transactionHash: string | undefined
let pieceCid: PieceCID | undefined
function getIpniAdvertisementMsg(attemptCount: number): string {
return `Checking for IPNI provider records (check #${attemptCount})`
}
const uploadResult = await executeUpload(synapseService, carData, rootCid, {
logger,
contextId: `${contextType}-${Date.now()}`,
...(pieceMetadata && { pieceMetadata }),
onProgress(event) {
switch (event.type) {
case 'onUploadComplete': {
pieceCid = event.data.pieceCid
flow.completeOperation('upload', 'Upload complete', {
type: 'success',
details: (() => {
const serviceURL = getServiceURL(synapseService.providerInfo)
if (serviceURL != null && serviceURL !== '') {
return {
title: 'Download IPFS CAR from SP',
content: [pc.gray(`${serviceURL.replace(/\/$/, '')}/ipfs/${rootCid}`)],
}
}
return
})(),
})
// Start adding piece to dataset operation
flow.addOperation('add-to-dataset', 'Adding piece to DataSet...')
break
}
case 'onPieceAdded': {
if (event.data.txHash) {
transactionHash = event.data.txHash
}
const network = synapseService.synapse.getNetwork()
const explorerUrls = [pc.gray(`Piece: https://pdp.vxb.ai/${network}/piece/${pieceCid}`)]
if (transactionHash) {
const filfoxBase = network === 'mainnet' ? 'https://filfox.info' : `https://${network}.filfox.info`
explorerUrls.push(pc.gray(`Transaction: ${filfoxBase}/en/message/${transactionHash}`))
}
flow.completeOperation('add-to-dataset', 'Piece added to DataSet (unconfirmed on-chain)', {
type: 'success',
details: {
title: 'Explorer URLs',
content: explorerUrls,
},
})
// Start chain confirmation operation
flow.addOperation('chain', 'Confirming piece added to DataSet on-chain')
break
}
case 'onPieceConfirmed': {
flow.completeOperation('chain', 'Piece added to DataSet (confirmed on-chain)', {
type: 'success',
})
break
}
case 'ipniProviderResults.retryUpdate': {
const attemptCount = event.data.retryCount === 0 ? 1 : event.data.retryCount + 1
flow.addOperation('ipni', getIpniAdvertisementMsg(attemptCount))
break
}
case 'ipniProviderResults.complete': {
// complete event is only emitted when result === true (success)
flow.completeOperation('ipni', 'IPNI provider records found. IPFS retrieval possible.', {
type: 'success',
details: {
title: 'IPFS Retrieval URLs',
content: [
pc.gray(`ipfs://${rootCid}`),
pc.gray(`https://inbrowser.link/ipfs/${rootCid}`),
pc.gray(`https://dweb.link/ipfs/${rootCid}`),
],
},
})
break
}
case 'ipniProviderResults.failed': {
flow.completeOperation('ipni', 'IPNI provider records not found.', {
type: 'warning',
details: {
title: 'IPFS retrieval is not possible yet.',
content: [pc.gray(`IPNI provider records for this SP does not exist for the provided root CID`)],
},
})
break
}
default: {
break
}
}
},
})
return {
...uploadResult,
network: synapseService.synapse.getNetwork(),
transactionHash: uploadResult.transactionHash,
}
}
/**
* Display results for import or add command
*
* @param result - Result data to display
* @param operation - Operation name ('Import' or 'Add')
*/
export function displayUploadResults(
result: {
filePath: string
fileSize: number
rootCid: string
pieceCid: string
pieceId?: number | undefined
dataSetId: string
providerInfo: any
transactionHash?: string | undefined
},
operation: string,
network: string
): void {
log.line(`Network: ${pc.bold(network)}`)
log.line('')
log.line(pc.bold(`${operation} Details`))
log.indent(`File: ${result.filePath}`)
log.indent(`Size: ${formatFileSize(result.fileSize)}`)
log.indent(`Root CID: ${result.rootCid}`)
log.line('')
log.line(pc.bold('Filecoin Storage'))
log.indent(`Piece CID: ${result.pieceCid}`)
log.indent(`Piece ID: ${result.pieceId?.toString() || 'N/A'}`)
log.indent(`Data Set ID: ${result.dataSetId}`)
log.line('')
log.line(pc.bold('Storage Provider'))
log.indent(`Provider ID: ${result.providerInfo.id}`)
log.indent(`Name: ${result.providerInfo.name}`)
const downloadURL = getDownloadURL(result.providerInfo, result.pieceCid)
if (downloadURL) {
log.indent(`Direct Download URL: ${downloadURL}`)
}
if (result.transactionHash) {
log.line('')
log.line(pc.bold('Transaction'))
log.indent(`Hash: ${result.transactionHash}`)
}
log.flush()
}