filecoin-pin
Version:
Bridge IPFS content to Filecoin Onchain Cloud using familiar tools
312 lines • 15.4 kB
JavaScript
/**
* 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