UNPKG

filecoin-pin

Version:

Bridge IPFS content to Filecoin Onchain Cloud using familiar tools

400 lines (345 loc) 14.5 kB
/** * Payment status display command * * Shows current payment configuration and balances for Filecoin Onchain Cloud. * This provides a quick overview of the user's payment setup without making changes. */ import { SIZE_CONSTANTS } from '@filoz/synapse-core/utils' import type { Synapse } from '@filoz/synapse-sdk' import { TIME_CONSTANTS } from '@filoz/synapse-sdk' import { ethers } from 'ethers' import pc from 'picocolors' import { TELEMETRY_CLI_APP_NAME } from '../common/constants.js' import { type ActualStorageResult, calculateActualStorage, listDataSets } from '../core/data-set/index.js' import { calculateDepositCapacity, calculateStorageRunway, checkFILBalance, checkUSDFCBalance, FLOOR_PRICE_DAYS, FLOOR_PRICE_PER_30_DAYS, getPaymentStatus, } from '../core/payments/index.js' import { cleanupSynapseService, initializeSynapse } from '../core/synapse/index.js' import { formatFIL, formatUSDFC } from '../core/utils/format.js' import { formatRunwaySummary } from '../core/utils/index.js' import { type CLIAuthOptions, getCLILogger, parseCLIAuth } from '../utils/cli-auth.js' import { cancel, createSpinner, formatFileSize, intro, outro } from '../utils/cli-helpers.js' import { log } from '../utils/cli-logger.js' import { displayDepositWarning } from './setup.js' interface StatusOptions extends CLIAuthOptions { includeRails?: boolean } const STORAGE_DISPLAY_PRECISION_DIGITS = 6 const STORAGE_DISPLAY_PRECISION = 10n ** BigInt(STORAGE_DISPLAY_PRECISION_DIGITS) const { TiB } = SIZE_CONSTANTS /** * Convert a payment rate (USDFC per epoch) to storage bytes using the provider's pricing. * * This calculates: "How much storage does this payment rate cover?" * * Formula: storageBytes = (rate / pricePerTiBPerEpoch) * TiB * * NOTE: This calculation assumes a linear relationship between payment rate and * storage size, which breaks down when floor pricing is applied to small files. * The result represents "storage equivalent" at the given rate, not actual bytes stored. * Use calculateActualStorage() from core/data-set for accurate byte counts. * * @param ratePerEpoch - Payment rate in USDFC per epoch * @param pricePerTiBPerEpoch - Provider's price for 1 TiB per epoch in USDFC * @returns Storage bytes that the rate covers, or null if invalid inputs */ function convertRateToStorageBytes(ratePerEpoch: bigint, pricePerTiBPerEpoch: bigint): bigint | null { if (ratePerEpoch <= 0n || pricePerTiBPerEpoch <= 0n) { return null } // storageTiBScaled preserves fractional precision using STORAGE_DISPLAY_PRECISION scaling const storageTiBScaled = (ratePerEpoch * STORAGE_DISPLAY_PRECISION) / pricePerTiBPerEpoch if (storageTiBScaled <= 0n) { return null } // Convert scaled TiB to bytes: TiB * 1024^4 bytes, then unscale return (storageTiBScaled * TiB) / STORAGE_DISPLAY_PRECISION } /** * Display current payment status * * @param options - Options from command line */ export async function showPaymentStatus(options: StatusOptions): Promise<void> { intro(pc.bold('Filecoin Onchain Cloud Payment Status')) const spinner = createSpinner() spinner.start('Fetching current configuration...') try { // Parse and validate authentication const authConfig = parseCLIAuth(options) const logger = getCLILogger() const synapse = await initializeSynapse( { ...authConfig, telemetry: { sentrySetTags: { appName: TELEMETRY_CLI_APP_NAME } } }, logger ) const network = synapse.getNetwork() const client = synapse.getClient() const address = await client.getAddress() // Check balances and status const filStatus = await checkFILBalance(synapse) // Early exit if account has no funds if (filStatus.balance === 0n) { spinner.stop('━━━ Current Status ━━━') log.line(`Address: ${address}`) log.line(`Network: ${network}`) log.line('') log.line(`${pc.red('✗')} Account has no FIL balance`) log.line('') log.line( `Get test FIL from: ${filStatus.isCalibnet ? 'https://faucet.calibnet.chainsafe-fil.io/' : 'Purchase FIL from an exchange'}` ) log.flush() cancel('Account not funded') throw new Error('Account has no FIL balance') } const walletUsdfcBalance = await checkUSDFCBalance(synapse) // Check if we have USDFC tokens before continuing if (walletUsdfcBalance === 0n) { spinner.stop('━━━ Current Status ━━━') log.line(`Address: ${address}`) log.line(`Network: ${network}`) log.line('') log.line(`${pc.red('✗')} No USDFC tokens found`) log.line('') const helpMessage = filStatus.isCalibnet ? 'Get test USDFC from: https://docs.secured.finance/usdfc-stablecoin/getting-started/getting-test-usdfc-on-testnet' : 'Mint USDFC with FIL: https://docs.secured.finance/usdfc-stablecoin/getting-started/minting-usdfc-step-by-step' log.line(` ${pc.cyan(helpMessage)}`) log.flush() cancel('USDFC required to use Filecoin Onchain Cloud') throw new Error('No USDFC tokens found') } const status = await getPaymentStatus(synapse) // Get storage pricing for capacity calculation and spend summaries const storageInfo = await synapse.storage.getStorageInfo() const pricePerTiBPerEpoch = storageInfo.pricing.noCDN.perTiBPerEpoch let paymentRailsData: PaymentRailsData | null = null if (options.includeRails === true) { paymentRailsData = await fetchPaymentRailsData(synapse) } spinner.stop(`${pc.green('✓')} Configuration loaded`) // Display all status information log.line('━━━ Current Status ━━━') // Show wallet balances log.line(pc.bold('Wallet')) log.indent(`Owner address: ${address}`) log.indent(`Network: ${network}`) log.indent(`FIL: ${formatFIL(filStatus.balance, filStatus.isCalibnet)}`) log.indent(`USDFC: ${formatUSDFC(walletUsdfcBalance)} USDFC`) log.line('') // Show deposit and capacity const lockupUsed = status.currentAllowances.lockupUsed ?? 0n const rateUsed = status.currentAllowances.rateUsed ?? 0n const availableDeposit = status.filecoinPayBalance > lockupUsed ? status.filecoinPayBalance - lockupUsed : 0n const capacity = calculateDepositCapacity(status.filecoinPayBalance, pricePerTiBPerEpoch) const runway = calculateStorageRunway(status) const runwayDisplay = formatRunwaySummary(runway) const dailyCost = runway.perDay const monthlyCost = dailyCost * TIME_CONSTANTS.DAYS_PER_MONTH log.line(pc.bold('Filecoin Pay')) log.indent(`Balance: ${formatUSDFC(status.filecoinPayBalance)} USDFC`) log.indent(`Locked: ${formatUSDFC(lockupUsed)} USDFC (30-day reserve)`) log.indent(`Available: ${formatUSDFC(availableDeposit)} USDFC`) if (rateUsed > 0n) { log.indent(`Epoch cost: ${formatUSDFC(rateUsed)} USDFC`) log.indent(`Daily cost: ${formatUSDFC(dailyCost)} USDFC`) log.indent(`Monthly cost: ${formatUSDFC(monthlyCost)} USDFC`) } else { log.indent(`Epoch cost: ${pc.gray('0 USDFC')}`) log.indent(`Daily cost: ${pc.gray('0 USDFC')}`) log.indent(`Monthly cost: ${pc.gray('0 USDFC')}`) } if (paymentRailsData != null) { displayPaymentRailsSummary(paymentRailsData, 1) } log.line('') // Show storage usage details log.line(pc.bold('WarmStorage Usage')) let actualStorageResult: ActualStorageResult | null = null try { spinner.start('Fetching data sets...') // Get all active data sets for this address const dataSets = await listDataSets(synapse, { withProviderDetails: false, address, filter: (ds) => ds.isLive, // Only count active/live data sets logger, }) spinner.stop(`${pc.green('✓')} Data sets fetched`) spinner.start('Calculating actual storage from data sets...') actualStorageResult = await calculateActualStorage(synapse, dataSets, { logger, onProgress: (progress) => { if (progress.type === 'actual-storage:progress') { spinner.message( `Calculating actual storage from data sets (${progress.data.dataSetsProcessed}/${progress.data.dataSetCount})` ) } }, }) if (actualStorageResult.timedOut) { spinner.stop(`${pc.yellow('⚠')} Calculation timed out`) } else if (actualStorageResult.warnings.length > 0) { spinner.stop( `${pc.yellow('⚠')} Actual storage calculated with ${actualStorageResult.warnings.length} warning(s)` ) } else { spinner.stop(`${pc.green('✓')} Actual storage calculated`) } if (actualStorageResult.warnings.length > 0) { for (const warning of actualStorageResult.warnings) { log.indent(pc.yellow(`⚠ ${warning.message}`)) } } if (actualStorageResult.totalBytes > 0n) { const formattedSize = formatFileSize(actualStorageResult.totalBytes) log.indent(`Stored: ${formattedSize}`) } else { log.indent(pc.gray('Stored: 0 B')) } } catch (error) { spinner.stop(`${pc.yellow('⚠')} Could not calculate actual storage`) log.indent(pc.gray(` Error: ${error instanceof Error ? error.message : String(error)}`)) } if (runway.state === 'active') { log.indent(`Runway: ~${runwayDisplay}`) } else { log.indent(pc.gray(`Runway: ${runwayDisplay}`)) } const capacityTibPerMonth = ethers.parseUnits(capacity.tibPerMonth.toString(), 18) const capacityBytes = (capacityTibPerMonth * TiB) / 10n ** 18n const capacityLine = `Funding could cover ~${formatFileSize(capacityBytes)} for one month` log.indent(capacityLine) log.flush() const billedBytes = convertRateToStorageBytes(rateUsed, pricePerTiBPerEpoch) if (billedBytes != null) { // Calculate what storage the floor price represents at current pricing const epochsInFloorPeriod = BigInt(FLOOR_PRICE_DAYS) * TIME_CONSTANTS.EPOCHS_PER_DAY const floorRatePerEpoch = FLOOR_PRICE_PER_30_DAYS / epochsInFloorPeriod const floorEquivalentBytes = convertRateToStorageBytes(floorRatePerEpoch, pricePerTiBPerEpoch) const floorEquivalentFormatted = floorEquivalentBytes ? formatFileSize(floorEquivalentBytes) : '~24.6 GiB' const sectionContent = [ pc.gray('Filecoin Onchain Cloud uses floor pricing for DataSets.'), pc.gray(`Each DataSet is billed a minimum of ${formatUSDFC(FLOOR_PRICE_PER_30_DAYS, 2)} USDFC per 30 days.`), pc.gray(`This is equivalent to ~${floorEquivalentFormatted} per month.`), `Billed capacity: ~${formatFileSize(billedBytes)}`, ] if (actualStorageResult != null && billedBytes > actualStorageResult.totalBytes) { const additionalStorage = billedBytes - actualStorageResult.totalBytes sectionContent.push(`Storage remaining: ~${formatFileSize(additionalStorage)}`) } log.indent(pc.bold('Storage usage details:')) for (const content of sectionContent) { log.indent(content, 2) } } // Show deposit warning if needed displayDepositWarning(status.filecoinPayBalance, status.currentAllowances.lockupUsed) log.flush() // Show success outro outro('Status check complete') } catch (error) { spinner.stop(`${pc.red('✗')} Status check failed`) log.line('') log.line(`${pc.red('Error:')} ${error instanceof Error ? error.message : String(error)}`) log.flush() cancel('Status check failed') process.exitCode = 1 } finally { await cleanupSynapseService() } } interface PaymentRailsData { activeRails: number terminatedRails: number totalActiveRate: bigint totalPendingSettlements: bigint railsNeedingSettlement: number error?: string } /** * Fetch payment rails data without displaying anything */ async function fetchPaymentRailsData(synapse: Synapse): Promise<PaymentRailsData> { try { // Get rails as payer const payerRails = await synapse.payments.getRailsAsPayer() if (payerRails.length === 0) { return { activeRails: 0, terminatedRails: 0, totalActiveRate: 0n, totalPendingSettlements: 0n, railsNeedingSettlement: 0, } } // Analyze rails for summary let totalPendingSettlements = 0n let totalActiveRate = 0n let activeRails = 0 let terminatedRails = 0 let railsNeedingSettlement = 0 for (const rail of payerRails) { try { const railDetails = await synapse.payments.getRail(rail.railId) const settlementPreview = await synapse.payments.getSettlementAmounts(rail.railId) if (rail.isTerminated) { terminatedRails++ } else { activeRails++ totalActiveRate += railDetails.paymentRate } // Check for pending settlements if (settlementPreview.totalSettledAmount > 0n) { totalPendingSettlements += settlementPreview.totalSettledAmount railsNeedingSettlement++ } } catch (error) { log.warn(`Could not analyze rail ${rail.railId}: ${error instanceof Error ? error.message : String(error)}`) } } return { activeRails, terminatedRails, totalActiveRate, totalPendingSettlements, railsNeedingSettlement, } } catch { return { activeRails: 0, terminatedRails: 0, totalActiveRate: 0n, totalPendingSettlements: 0n, railsNeedingSettlement: 0, error: 'Unable to fetch rail information', } } } /** * Display payment rails summary */ function displayPaymentRailsSummary(data: PaymentRailsData, indentLevel: number = 1): void { log.indent(pc.bold('Payment Rails'), indentLevel) if (data.error) { log.indent(pc.gray(data.error), indentLevel + 1) return } if (data.activeRails === 0 && data.terminatedRails === 0) { log.indent(pc.gray('No active payment rails'), indentLevel + 1) return } log.indent(`${data.activeRails} active, ${data.terminatedRails} terminated`, indentLevel + 1) if (data.totalPendingSettlements > 0n) { log.indent(`Pending settlement: ${formatUSDFC(data.totalPendingSettlements)} USDFC`, indentLevel + 1) } if (data.railsNeedingSettlement > 0) { log.indent(`${data.railsNeedingSettlement} rail(s) need settlement`, indentLevel + 1) } }