filecoin-pin
Version:
Bridge IPFS content to Filecoin Onchain Cloud using familiar tools
379 lines (340 loc) • 14.6 kB
text/typescript
/**
* 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()
}
}