UNPKG

filecoin-pin

Version:

Bridge IPFS content to Filecoin Onchain Cloud using familiar tools

312 lines 15.4 kB
/** * payments fund command * * Adjusts funds to exactly match a target runway (days) or a target deposited amount. */ import { confirm } from '@clack/prompts'; import { 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 { cancel, createSpinner, intro, isInteractive, outro } from '../utils/cli-helpers.js'; import { isTTY, log } from '../utils/cli-logger.js'; // Helper: confirm/warn or bail when target implies < lockup-days runway async function ensureBelowThirtyDaysAllowed(opts) { 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) { 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, title = 'Updated') { 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) { 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) { 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 = hasDays ? Number(options.days) : 0; // user provided amount or 0n if not provided let targetDeposit = 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; let clampedTarget = null; let runwayCheckDays = null; let alreadyMessage; let depositMsg; let withdrawMsg; 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(); } } //# sourceMappingURL=fund.js.map