UNPKG

filecoin-pin

Version:

Bridge IPFS content to Filecoin Onchain Cloud using familiar tools

379 lines (340 loc) 14.6 kB
/** * payments fund command * * Adjusts funds to exactly match a target runway (days) or a target deposited amount. */ import { confirm } from '@clack/prompts' import { type Synapse, TIME_CONSTANTS } from '@filoz/synapse-sdk' import { ethers } from 'ethers' import pc from 'picocolors' import { MIN_RUNWAY_DAYS, TELEMETRY_CLI_APP_NAME } from '../common/constants.js' import { calculateStorageRunway, checkAndSetAllowances, checkFILBalance, checkUSDFCBalance, computeAdjustmentForExactDays, computeAdjustmentForExactDaysWithPiece, computeAdjustmentForExactDeposit, DEFAULT_LOCKUP_DAYS, depositUSDFC, getPaymentStatus, validatePaymentRequirements, withdrawUSDFC, } from '../core/payments/index.js' import { cleanupSynapseService, initializeSynapse } from '../core/synapse/index.js' import { formatUSDFC } from '../core/utils/format.js' import { formatRunwaySummary } from '../core/utils/index.js' import { getCLILogger, parseCLIAuth } from '../utils/cli-auth.js' import type { Spinner } from '../utils/cli-helpers.js' import { cancel, createSpinner, intro, isInteractive, outro } from '../utils/cli-helpers.js' import { isTTY, log } from '../utils/cli-logger.js' import type { AutoFundOptions, FundingAdjustmentResult, FundOptions } from './types.js' // Helper: confirm/warn or bail when target implies < lockup-days runway async function ensureBelowThirtyDaysAllowed(opts: { spinner: Spinner warningLine1: string warningLine2: string }): Promise<void> { const { spinner, warningLine1, warningLine2 } = opts if (!isInteractive()) { spinner.stop() console.error(pc.red(warningLine1)) console.error(pc.red(warningLine2)) cancel('Fund adjustment aborted') throw new Error(`Unsafe target below ${DEFAULT_LOCKUP_DAYS}-day baseline`) } log.line(pc.yellow('⚠ Warning')) log.indent(pc.yellow(warningLine1)) log.indent(pc.yellow(warningLine2)) log.flush() const proceed = await confirm({ message: 'Proceed with reducing runway below 30 days?', initialValue: false, }) if (!proceed) { throw new Error('Fund adjustment cancelled by user') } } // Helper: perform deposit or withdraw according to delta async function performAdjustment(params: { synapse: Synapse spinner: Spinner delta: bigint depositMsg: string withdrawMsg: string }): Promise<void> { const { synapse, spinner, delta, depositMsg, withdrawMsg } = params if (delta > 0n) { const needed = delta const usdfcWallet = await checkUSDFCBalance(synapse) if (needed > usdfcWallet) { console.error( pc.red( `✗ Insufficient USDFC in wallet (need ${formatUSDFC(needed)} USDFC, have ${formatUSDFC(usdfcWallet)} USDFC)` ) ) throw new Error('Insufficient USDFC in wallet') } if (isTTY()) { // we will deposit `needed` USDFC, display confirmation to user unless not TTY or --auto flag was passed const proceed = await confirm({ message: `Deposit ${formatUSDFC(needed)} USDFC?`, initialValue: false, }) if (!proceed) { throw new Error('Deposit cancelled by user') } } spinner.start(depositMsg) const { depositTx } = await depositUSDFC(synapse, needed) spinner.stop(`${pc.green('✓')} Deposit complete`) log.line(pc.bold('Transaction details:')) log.indent(pc.gray(`Deposit: ${depositTx}`)) log.flush() } else if (delta < 0n) { const withdrawAmount = -delta if (isTTY()) { // we will withdraw `withdrawAmount` USDFC, display confirmation to user unless not TTY or --auto flag was passed const proceed = await confirm({ message: `Withdraw ${formatUSDFC(withdrawAmount)} USDFC?`, initialValue: false, }) if (!proceed) { throw new Error('Withdraw cancelled by user') } } spinner.start(withdrawMsg) const txHash = await withdrawUSDFC(synapse, withdrawAmount) spinner.stop(`${pc.green('✓')} Withdraw complete`) log.line(pc.bold('Transaction')) log.indent(pc.gray(txHash)) log.flush() } } // Helper: summary after adjustment async function printSummary(synapse: Synapse, title = 'Updated'): Promise<void> { const updated = await getPaymentStatus(synapse) const runway = calculateStorageRunway(updated) const runwayDisplay = formatRunwaySummary(runway) log.section(title, [ `Deposited: ${formatUSDFC(updated.filecoinPayBalance)} USDFC`, runway.state === 'active' ? `Runway: ~${runwayDisplay}` : `Runway: ${runwayDisplay}`, ]) } /** * Automatically adjust funding to meet target runway or deposit amount. * This is a non-interactive version suitable for programmatic use. * * @param options - Auto-funding options * @returns Funding adjustment result * @throws Error if adjustment fails or target is unsafe */ export async function autoFund(options: AutoFundOptions): Promise<FundingAdjustmentResult> { const { synapse, fileSize, spinner } = options spinner?.message('Checking wallet readiness...') const [filStatus, walletUsdfcBalance] = await Promise.all([checkFILBalance(synapse), checkUSDFCBalance(synapse)]) const validation = validatePaymentRequirements(filStatus.hasSufficientGas, walletUsdfcBalance, filStatus.isCalibnet) if (!validation.isValid) { const help = validation.helpMessage ? ` ${validation.helpMessage}` : '' throw new Error(`${validation.errorMessage}${help}`) } spinner?.message('Ensuring WarmStorage permissions...') const allowanceResult = await checkAndSetAllowances(synapse) spinner?.message( allowanceResult.updated ? 'WarmStorage permissions configured' : 'WarmStorage permissions already configured' ) spinner?.message('Calculating funding requirements...') // Get current payment status and pricing after ensuring permissions const [status, storageInfo] = await Promise.all([getPaymentStatus(synapse), synapse.storage.getStorageInfo()]) const pricePerTiBPerEpoch = storageInfo.pricing.noCDN.perTiBPerEpoch // Calculate funding needed to maintain MIN_RUNWAY_DAYS after uploading this piece // This accounts for both the piece's lockup AND its impact on ongoing costs const adj = computeAdjustmentForExactDaysWithPiece(status, MIN_RUNWAY_DAYS, fileSize, pricePerTiBPerEpoch) const delta = adj.delta // Auto-fund only deposits, never withdraws if (delta <= 0n) { spinner?.message('No additional funding required') // Funding already sufficient const updated = await getPaymentStatus(synapse) const newAvailable = updated.filecoinPayBalance - (updated.currentAllowances.lockupUsed ?? 0n) const newPerDay = (updated.currentAllowances.rateUsed ?? 0n) * TIME_CONSTANTS.EPOCHS_PER_DAY const newRunway = newPerDay > 0n ? Number(newAvailable / newPerDay) : 0 const newRunwayHours = newPerDay > 0n ? Number(((newAvailable % newPerDay) * 24n) / newPerDay) : 0 return { adjusted: false, delta: 0n, newDepositedAmount: updated.filecoinPayBalance, newRunwayDays: newRunway, newRunwayHours: newRunwayHours, } } if (delta > walletUsdfcBalance) { throw new Error( `Insufficient USDFC in wallet (need ${formatUSDFC(delta)} USDFC, have ${formatUSDFC(walletUsdfcBalance)} USDFC)` ) } // Perform deposit const depositMsg = `Depositing ${formatUSDFC(delta)} USDFC to ensure ${MIN_RUNWAY_DAYS} day(s) runway...` spinner?.message(depositMsg) const depositResult = await depositUSDFC(synapse, delta) const transactionHash = depositResult.depositTx spinner?.message(`${pc.green('✓')} Deposit complete`) // Get updated status const updated = await getPaymentStatus(synapse) const newAvailable = updated.filecoinPayBalance - (updated.currentAllowances.lockupUsed ?? 0n) const newPerDay = (updated.currentAllowances.rateUsed ?? 0n) * TIME_CONSTANTS.EPOCHS_PER_DAY const newRunway = newPerDay > 0n ? Number(newAvailable / newPerDay) : 0 const newRunwayHours = newPerDay > 0n ? Number(((newAvailable % newPerDay) * 24n) / newPerDay) : 0 return { adjusted: true, delta, transactionHash, newDepositedAmount: updated.filecoinPayBalance, newRunwayDays: newRunway, newRunwayHours: newRunwayHours, } } export async function runFund(options: FundOptions): Promise<void> { intro(pc.bold('Filecoin Onchain Cloud Fund Adjustment')) const spinner = createSpinner() // Validate inputs const hasDays = options.days != null const hasAmount = options.amount != null if ((hasDays && hasAmount) || (!hasDays && !hasAmount)) { console.error(pc.red('Error: Specify exactly one of --days <N> or --amount <USDFC>')) throw new Error('Invalid fund options') } if (options.mode != null && !['exact', 'minimum'].includes(options.mode)) { console.error(pc.red('Error: Invalid mode')) throw new Error(`Invalid mode (must be "exact" or "minimum"), received: '${options.mode}'`) } spinner.start('Connecting...') 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 filStatus = await checkFILBalance(synapse) if (!filStatus.hasSufficientGas) { spinner.stop() log.line(`${pc.red('✗')} Insufficient FIL for gas fees`) const help = filStatus.isCalibnet ? 'Get test FIL from: https://faucet.calibnet.chainsafe-fil.io/' : 'Acquire FIL for gas from an exchange' log.line(` ${pc.cyan(help)}`) log.flush() cancel('Fund adjustment aborted') throw new Error('Insufficient FIL for gas fees') } const status = await getPaymentStatus(synapse) // Finish connection phase spinner before proceeding spinner.stop(`${pc.green('✓')} Connected`) // Unified planning: derive delta and target context for both modes const rateUsed = status.currentAllowances.rateUsed ?? 0n const lockupUsed = status.currentAllowances.lockupUsed ?? 0n // user provided days or 0 if not provided const targetDays: number = hasDays ? Number(options.days) : 0 // user provided amount or 0n if not provided let targetDeposit: bigint = 0n try { targetDeposit = options.amount != null ? ethers.parseUnits(String(options.amount), 18) : 0n } catch { console.error(pc.red(`Error: Invalid --amount '${options.amount}'`)) throw new Error('Invalid --amount') } let delta: bigint let clampedTarget: bigint | null = null let runwayCheckDays: number | null = null let alreadyMessage: string let depositMsg: string let withdrawMsg: string if (hasDays) { if (!Number.isFinite(targetDays) || targetDays < 0) { console.error(pc.red('Error: --days must be a non-negative number')) throw new Error('Invalid --days') } const adj = computeAdjustmentForExactDays(status, targetDays) if (adj.rateUsed === 0n) { log.line(`${pc.red('✗')} No active spend detected (rateUsed = 0). Cannot compute runway.`) log.line('Use --amount to set a target deposit instead.') log.flush() cancel('Fund adjustment aborted') throw new Error('No active spend') } delta = adj.delta runwayCheckDays = targetDays alreadyMessage = `Already at target of ~${targetDays} day(s). No changes needed.` depositMsg = `Depositing ${formatUSDFC(delta)} USDFC to reach ~${targetDays} day(s) runway...` withdrawMsg = `Withdrawing ${formatUSDFC(-delta)} USDFC to reach ~${targetDays} day(s) runway...` } else { const adj = computeAdjustmentForExactDeposit(status, targetDeposit) delta = adj.delta clampedTarget = adj.clampedTarget if (targetDeposit < lockupUsed && options.mode !== 'minimum') { log.line(pc.yellow('⚠ Target amount is below locked funds. Clamping to locked amount.')) log.indent(`Locked: ${formatUSDFC(lockupUsed)} USDFC`) log.flush() } if (rateUsed > 0n) { const perDay = rateUsed * TIME_CONSTANTS.EPOCHS_PER_DAY const availableAfter = clampedTarget > lockupUsed ? clampedTarget - lockupUsed : 0n runwayCheckDays = Number(availableAfter / perDay) } const targetLabel = clampedTarget != null ? formatUSDFC(clampedTarget) : String(options.amount) alreadyMessage = `Already at target deposit of ${targetLabel} USDFC. No changes needed.` depositMsg = `Depositing ${formatUSDFC(delta)} USDFC to reach ${targetLabel} USDFC total...` withdrawMsg = `Withdrawing ${formatUSDFC(-delta)} USDFC to reach ${targetLabel} USDFC total...` } if (options.mode === 'minimum') { if (delta > 0n) { if (hasAmount) { depositMsg = `Depositing ${formatUSDFC(delta)} USDFC to reach minimum of ${formatUSDFC( targetDeposit )} USDFC total...` } else if (targetDays > 0) { depositMsg = `Depositing ${formatUSDFC(delta)} USDFC to reach minimum of ${targetDays} day(s) runway...` } } else { if (delta < 0n) { if (hasAmount) { alreadyMessage = `Already above minimum deposit of ${formatUSDFC(targetDeposit)} USDFC. No changes needed.` } else if (targetDays > 0) { alreadyMessage = `Already above minimum of ${targetDays} day(s) runway. No changes needed.` } } delta = 0n } } else if (runwayCheckDays != null && runwayCheckDays < Number(TIME_CONSTANTS.DEFAULT_LOCKUP_DAYS)) { const line1 = hasDays ? 'Requested runway below 30-day safety baseline.' : 'Target deposit implies less than 30 days of runway at current spend.' const line2 = hasDays ? 'WarmStorage reserves 30 days of costs; a shorter runway risks termination.' : 'Increase target or accept risk: shorter runway may cause termination.' await ensureBelowThirtyDaysAllowed({ spinner, warningLine1: line1, warningLine2: line2, }) } if (delta === 0n) { await printSummary(synapse, 'No Changes Needed') outro(alreadyMessage) return } await performAdjustment({ synapse, spinner, delta, depositMsg, withdrawMsg }) await printSummary(synapse) outro('Fund adjustment completed') } catch (error) { spinner.stop() console.error(pc.red('✗ Fund adjustment failed')) console.error(pc.red('Error:'), error instanceof Error ? error.message : error) process.exitCode = 1 } finally { await cleanupSynapseService() } }