diff --git a/README.md b/README.md index e06a9948..6240b177 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,20 @@ filecoin-pin data-set filecoin-pin add myfile.txt --network calibration ``` +### Fund from another EVM chain + +On Filecoin Mainnet, interactive `payments fund` can use [Squid](documentation/glossary.md#squid) when the owner wallet is short of FIL for gas or USDFC for the requested deposit. It supports Filecoin, Arbitrum, Ethereum, Base, Optimism, Polygon, Avalanche, and BNB Chain, and accepts `native`, a token address, or an unambiguous token symbol from Squid's current catalog. + +```bash +filecoin-pin payments fund --days 30 \ + --from-chain arbitrum \ + --from-token USDC \ + --max-source-amount 10 \ + --source-rpc-url https://your-arbitrum-rpc.example +``` + +Filecoin Pin uses the public `filecoin-testing-94a4a25a-d40b-41cb-b148-e96098862` Squid integrator ID by default; `SQUID_INTEGRATOR_ID` can override it. The same private key must control the wallet on both chains. Filecoin Pin shows the planned source spend and asks for confirmation before signing. If the process is interrupted, the next attempt prints the path of a pending marker; verify both chains manually before deleting that file and retrying. Calibration, devnet, session keys, and non-interactive acquisition are not supported. + For detailed guides, see: - **CLI**: [Complete CLI walkthrough](https://docs.filecoin.io/builder-cookbook/filecoin-pin/filecoin-pin-cli) - **GitHub Action**: [CI/CD integration guide](https://docs.filecoin.io/builder-cookbook/filecoin-pin/github-action) @@ -275,6 +289,9 @@ RPC_URL=wss://... # Filecoin RPC endpoint (overrides NETWORK if spe # Mainnet: wss://wss.node.glif.io/apigw/lotus/rpc/v1 # Calibration: wss://wss.calibration.node.glif.io/apigw/lotus/rpc/v1 +# Optional - interactive Mainnet funding through Squid +SQUID_INTEGRATOR_ID=... # Override the built-in public Squid integrator ID + # Optional for Pinning Server Daemon ACCESS_TOKEN=... # Bearer token required on all API requests except GET / ALLOW_NO_AUTH=true # Start without a token, serving all requests unauthenticated (not recommended) diff --git a/documentation/glossary.md b/documentation/glossary.md index fbf4dcad..5cc8cc8d 100644 --- a/documentation/glossary.md +++ b/documentation/glossary.md @@ -237,6 +237,9 @@ Session keys require specific permissions (such as CREATE_DATA_SET and ADD_PIECE Note that the filecoin-pin CLI's `--session-key` flag (and `SESSION_KEY` environment variable) expect the session key's **private key** — the `SESSION_KEY` value printed by `filecoin-pin session create` or `filecoin-pin session generate` — not the session address. The (public) session address is only used when authorizing or revoking: `filecoin-pin session authorize ` and `filecoin-pin session revoke `. +## Squid + +Squid is the cross-chain routing service [Filecoin Pin](#filecoin-pin) can use to acquire FIL or [USDFC](#usdfc) missing from a Filecoin Mainnet wallet during interactive funding. Supported tokens and available routes come from Squid at command time; see the [Squid API documentation](https://docs.squidrouter.com/api). ## Standard IPFS Tooling diff --git a/package.json b/package.json index 1b790e26..8a82ad36 100644 --- a/package.json +++ b/package.json @@ -152,6 +152,7 @@ "picocolors": "^1.1.1", "pino": "^10.3.1", "semver": "^7.7.4", + "@filecoin-project/squid-evm-funding": "^0.3.1", "varint": "^6.0.0", "viem": "^2.48.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 600c8291..90921ece 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@clack/prompts': specifier: ^1.0.1 version: 1.5.1 + '@filecoin-project/squid-evm-funding': + specifier: ^0.3.1 + version: 0.3.1(typescript@6.0.3)(zod@4.4.3) '@filoz/synapse-core': specifier: ^0.7.0 version: 0.7.1(typescript@6.0.3)(viem@2.55.10(typescript@6.0.3)(zod@4.4.3)) @@ -642,6 +645,10 @@ packages: '@fastify/proxy-addr@5.1.0': resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + '@filecoin-project/squid-evm-funding@0.3.1': + resolution: {integrity: sha512-MKipKkSruhTTNNIzQbXgkNUgP89Tx8Y+U2JYAs15xTKY7famHC/lv+WyHzG6PcFCvvRo9CguWPRX1iuOM8ffGw==} + engines: {node: '>=18'} + '@filoz/synapse-core@0.7.1': resolution: {integrity: sha512-4vZZJbumv64ZKR+BjfhLJvP57ga/GhSHy/k2jIWuXInXGwM5qPmKvaCS7CkpzQwiSIXahO161DBNRLVH8SrVfQ==} peerDependencies: @@ -2762,6 +2769,15 @@ snapshots: '@fastify/forwarded': 3.0.1 ipaddr.js: 2.3.0 + '@filecoin-project/squid-evm-funding@0.3.1(typescript@6.0.3)(zod@4.4.3)': + dependencies: + viem: 2.55.10(typescript@6.0.3)(zod@4.4.3) + transitivePeerDependencies: + - bufferutil + - typescript + - utf-8-validate + - zod + '@filoz/synapse-core@0.7.1(typescript@6.0.3)(viem@2.55.10(typescript@6.0.3)(zod@4.4.3))': dependencies: dnum: 2.17.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6ef090de..a70c518f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,8 +3,10 @@ packages: minimumReleaseAge: 10080 minimumReleaseAgeExclude: + - "@filecoin-project/squid-evm-funding" - "@filoz/*" onlyBuiltDependencies: - esbuild - node-datachannel - playwright + - "@filecoin-project/squid-evm-funding" diff --git a/src/commands/payments.ts b/src/commands/payments.ts index 1dfa093e..e906766b 100644 --- a/src/commands/payments.ts +++ b/src/commands/payments.ts @@ -6,7 +6,7 @@ import { runInteractiveSetup } from '../payments/interactive.js' import { showPaymentStatus } from '../payments/status.js' import type { FundOptions, PaymentSetupOptions } from '../payments/types.js' import { runWithdraw } from '../payments/withdraw.js' -import { addAuthOptions } from '../utils/cli-options.js' +import { addAuthOptions, addFundingSourceOptions } from '../utils/cli-options.js' export const paymentsCommand = new Command('payments').description( 'Manage storage payments (required before your first upload)' @@ -71,6 +71,7 @@ const fundCommand = new Command('fund') }) addAuthOptions(fundCommand) +addFundingSourceOptions(fundCommand) paymentsCommand.addCommand(fundCommand) // Withdraw command diff --git a/src/payments/fund.ts b/src/payments/fund.ts index b6e15800..cdc15abd 100644 --- a/src/payments/fund.ts +++ b/src/payments/fund.ts @@ -5,29 +5,34 @@ */ import { confirm, isCancel } from '@clack/prompts' -import type { Synapse } from '@filoz/synapse-sdk' +import { NATIVE_TOKEN_ADDRESS } from '@filecoin-project/squid-evm-funding' +import { type Synapse, TIME_CONSTANTS } from '@filoz/synapse-sdk' import pc from 'picocolors' -import { parseUnits } from 'viem' +import { formatUnits, parseUnits } from 'viem' import { CliFatal, CliIncomplete, isCliFatal, isCliIncomplete, setIncompleteExitCode } from '../common/cli-errors.js' import { MIN_RUNWAY_DAYS } from '../common/constants.js' import { resolveIpfsIndexedMetadata } from '../core/metadata/index.js' import { + calculateFilecoinPayFundingPlan, checkUSDFCBalance, clampDepositToLimit, DEFAULT_LOCKUP_DAYS, depositUSDFC, executeFilecoinPayFunding, + getPaymentStatus, + MIN_FIL_FOR_GAS, planFilecoinPayFunding, toStorageRunwaySummary, withdrawUSDFC, } from '../core/payments/index.js' -import { initializeSynapse } from '../core/synapse/index.js' +import { getClientAddress, 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 { acquirePaymentShortfalls, validateFundingSourceOptions } from './squid-funding.js' import type { AutoFundOptions, FundingAdjustmentResult, FundOptions } from './types.js' // Helper: confirm/warn or bail when target implies < lockup-days runway @@ -245,12 +250,13 @@ export async function runFund(options: FundOptions): Promise { spinner.start('Connecting...') try { + const sourceRequested = validateFundingSourceOptions(options) + // Parse and validate authentication const authConfig = parseCLIAuth(options) const logger = getCLILogger() const synapse = await initializeSynapse(authConfig, logger) - spinner.stop(`${pc.green('✓')} Connected`) const targetDays: number = hasDays ? Number(options.days) : 0 @@ -265,14 +271,62 @@ export async function runFund(options: FundOptions): Promise { throw new Error(`Invalid --amount '${options.amount}'`) } - spinner.start('Calculating funding plan...') - const planResult = await planFilecoinPayFunding({ - synapse, + const planOptions = { targetRunwayDays: hasDays ? targetDays : undefined, targetDeposit: hasAmount ? targetDeposit : undefined, mode: options.mode ?? 'exact', allowWithdraw: options.mode !== 'minimum', - }) + } as const + + spinner.start('Calculating funding plan...') + if (sourceRequested) { + const [status, accountSummary] = await Promise.all([ + getPaymentStatus(synapse), + synapse.payments.accountSummary({}), + ]) + const preview = calculateFilecoinPayFundingPlan({ status, accountSummary, ...planOptions }) + const filShortfall = + preview.delta > 0n && status.filBalance < MIN_FIL_FOR_GAS ? MIN_FIL_FOR_GAS - status.filBalance : 0n + const requiredWalletUsdfc = + preview.delta > 0n ? preview.delta + preview.current.spendRatePerEpoch * TIME_CONSTANTS.EPOCHS_PER_HOUR : 0n + const usdfcShortfall = + requiredWalletUsdfc > status.walletUsdfcBalance ? requiredWalletUsdfc - status.walletUsdfcBalance : 0n + if (filShortfall > 0n || usdfcShortfall > 0n) { + if (!isInteractive()) throw new Error('Squid source acquisition requires an interactive terminal') + await acquirePaymentShortfalls({ + synapse, + owner: getClientAddress(synapse), + filShortfall, + usdfcShortfall, + requiredWalletUsdfc, + options, + confirm: async (summary) => { + const spend = summary.quotes.reduce((total, quote) => total + quote.sourceAmount, 0n) + const nativeRouteFee = summary.quotes + .flatMap((quote) => quote.costs) + .filter( + (cost) => + cost.kind === 'fee' && + cost.token.chainId === summary.source.chainId && + cost.token.address?.toLowerCase() === NATIVE_TOKEN_ADDRESS + ) + .reduce((total, cost) => total + cost.amount, 0n) + const targets = summary.quotes + .map( + (quote) => + `${formatUnits(quote.requirement.amount, 18)} ${quote.requirement.id === 'filecoin-fil' ? 'FIL' : 'USDFC'}` + ) + .join(' and ') + const proceed = await confirm({ + message: `Spend ${formatUnits(spend, summary.source.decimals)} ${summary.source.symbol} from ${summary.sourceChainName} via Squid to receive ${targets} on Filecoin (source-token limit: ${formatUnits(summary.maxSourceAmount, summary.source.decimals)} ${summary.source.symbol}; estimated Squid route fee: ${formatUnits(nativeRouteFee, summary.nativeCurrency.decimals)} ${summary.nativeCurrency.symbol}; transaction-gas limit: ${formatUnits(summary.maxNativeFee, summary.nativeCurrency.decimals)} ${summary.nativeCurrency.symbol}; final network fees may vary)?`, + initialValue: false, + }) + if (isCancel(proceed) || !proceed) throw new CliIncomplete('Source acquisition cancelled by user') + }, + }) + } + } + const planResult = await planFilecoinPayFunding({ synapse, ...planOptions }) const { plan } = planResult spinner.stop(`${pc.green('✓')} Funding plan prepared`) diff --git a/src/payments/squid-funding.ts b/src/payments/squid-funding.ts new file mode 100644 index 00000000..44679fd1 --- /dev/null +++ b/src/payments/squid-funding.ts @@ -0,0 +1,287 @@ +import { mkdir, unlink, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { + type DestinationRequirement, + executeSquidFunding, + NATIVE_TOKEN_ADDRESS, + planSquidFunding, + type SourceToken, + type SquidPriceQuote, + type SquidPublicClient, + type SquidWalletClient, +} from '@filecoin-project/squid-evm-funding' +import type { Synapse } from '@filoz/synapse-sdk' +import { type Address, type Chain, createPublicClient, createWalletClient, getAddress, type Hex, http } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { arbitrum, avalanche, base, bsc, mainnet as ethereum, filecoin, optimism, polygon } from 'viem/chains' +import { publicActionsL2 } from 'viem/op-stack' +import { CliIncomplete } from '../common/cli-errors.js' +import { createConfig } from '../config.js' +import { MIN_FIL_FOR_GAS } from '../core/payments/index.js' +import { mainnet as filecoinMainnet } from '../core/synapse/index.js' +import type { CLIAuthOptions } from '../utils/cli-auth.js' +import type { FundingSourceOptions } from './types.js' + +const SQUID_ROUTER = getAddress('0xce16F69375520ab01377ce7B88f5BA8C48F8D666') +const DEFAULT_SQUID_INTEGRATOR_ID = 'filecoin-testing-94a4a25a-d40b-41cb-b148-e96098862' +const FILECOIN_USDFC = filecoinMainnet.contracts.usdfc.address +const REQUEST_TIMEOUT_MS = 15_000 +const SLIPPAGE_PERCENT = 1 +const PRICE_DRIFT_HEADROOM_PERCENT = 1n + +interface SourcePolicy { + chain: Chain + names: readonly string[] + /** Maximum buffered native-fee commitments, not a guaranteed final debit. */ + maxNativeFee: bigint + opStack?: boolean +} + +const SOURCE_POLICIES: readonly SourcePolicy[] = [ + { chain: filecoin, names: ['filecoin', 'fil'], maxNativeFee: 30_000_000_000_000_000n }, + { chain: arbitrum, names: ['arbitrum', 'arb'], maxNativeFee: 3_000_000_000_000_000n }, + { chain: ethereum, names: ['ethereum', 'eth'], maxNativeFee: 30_000_000_000_000_000n }, + { chain: base, names: ['base'], maxNativeFee: 3_000_000_000_000_000n, opStack: true }, + { chain: optimism, names: ['optimism', 'op'], maxNativeFee: 3_000_000_000_000_000n, opStack: true }, + { chain: polygon, names: ['polygon', 'matic'], maxNativeFee: 10_000_000_000_000_000n }, + { chain: avalanche, names: ['avalanche', 'avax'], maxNativeFee: 10_000_000_000_000_000n }, + { chain: bsc, names: ['bnb', 'bsc', 'bnb-chain'], maxNativeFee: 5_000_000_000_000_000n }, +] + +interface PaymentAcquisitionSummary { + source: SourceToken + sourceChainName: string + quotes: readonly SquidPriceQuote[] + maxSourceAmount: bigint + maxNativeFee: bigint + nativeCurrency: { symbol: string; decimals: number } +} + +export interface AcquirePaymentShortfallsInput { + synapse: Synapse + owner: Address + filShortfall: bigint + usdfcShortfall: bigint + /** Wallet USDFC reserved for the later Filecoin Pay deposit. */ + requiredWalletUsdfc: bigint + options: FundingSourceOptions & Pick + confirm: (summary: PaymentAcquisitionSummary) => Promise +} + +export function validateFundingSourceOptions(options: FundingSourceOptions): boolean { + const requested = [options.fromChain, options.fromToken, options.maxSourceAmount, options.sourceRpcUrl].some( + (value) => value != null && value.trim() !== '' + ) + if (!requested) return false + if ( + [options.fromChain, options.fromToken, options.maxSourceAmount, options.sourceRpcUrl].some( + (value) => !value?.trim() + ) + ) { + throw new Error( + 'Source acquisition requires --from-chain, --from-token, --max-source-amount, and --source-rpc-url together' + ) + } + return true +} + +function sourcePolicy(name: string | undefined): SourcePolicy { + const normalized = name?.trim().toLowerCase() + const policy = SOURCE_POLICIES.find((candidate) => candidate.names.includes(normalized ?? '')) + if (policy == null) throw new Error(`Unsupported source chain: ${name ?? '(missing)'}`) + return policy +} + +function signingAccount(value: string | undefined, owner: Address) { + if (value == null || value.trim() === '') throw new Error('Source acquisition requires owner private-key auth') + const account = privateKeyToAccount((value.startsWith('0x') ? value : `0x${value}`) as Hex) + if (account.address.toLowerCase() !== owner.toLowerCase()) { + throw new Error('The source private key must control the Filecoin payment owner') + } + return account +} + +function requirements(input: AcquirePaymentShortfallsInput, filFeeBuffer: bigint): DestinationRequirement[] { + return [ + ...(input.filShortfall > 0n + ? [ + { + id: 'filecoin-fil', + chainId: filecoinMainnet.id, + token: NATIVE_TOKEN_ADDRESS, + amount: input.filShortfall + filFeeBuffer, + recipient: input.owner, + }, + ] + : []), + ...(input.usdfcShortfall > 0n + ? [ + { + id: 'filecoin-usdfc', + chainId: filecoinMainnet.id, + token: FILECOIN_USDFC, + amount: input.usdfcShortfall, + recipient: input.owner, + }, + ] + : []), + ] +} + +function fetchWithTimeout(input: RequestInfo | URL, init?: RequestInit): Promise { + const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS) + const signal = init?.signal == null ? timeout : AbortSignal.any([init.signal, timeout]) + return fetch(input, { ...init, signal }) +} + +function safeMessage(error: unknown, secrets: readonly (string | undefined)[]): string { + let message = error instanceof Error ? error.message : String(error) + for (const secret of secrets) { + if (secret != null && secret !== '') message = message.replaceAll(secret, '[redacted]') + } + return message.replace(/\b(?:https?|wss?):\/\/[^\s'"`<>]+/giu, '[redacted RPC URL]') +} + +function markerPath(): string { + return join(dirname(createConfig().databasePath), 'squid-funding.pending') +} + +function pendingError(path: string): Error { + return new Error( + `A previous Squid funding attempt may still be pending. Verify the source and Filecoin transactions and balances, then delete ${path} before retrying.` + ) +} + +function makeSourceClients(policy: SourcePolicy, rpcUrl: string, privateKey: string | undefined, owner: Address) { + const account = signingAccount(privateKey, owner) + const transport = http(rpcUrl, { timeout: REQUEST_TIMEOUT_MS }) + const baseClient = createPublicClient({ chain: policy.chain, transport }) + const publicClient = policy.opStack === true ? baseClient.extend(publicActionsL2()) : baseClient + return { + account, + publicClient: publicClient as unknown as SquidPublicClient, + walletClient: createWalletClient({ account, chain: policy.chain, transport }) as unknown as SquidWalletClient, + } +} + +/** Acquire only the positive FIL and USDFC shortfalls supplied by the existing payment planner. */ +export async function acquirePaymentShortfalls(input: AcquirePaymentShortfallsInput): Promise { + if (input.filShortfall <= 0n && input.usdfcShortfall <= 0n) return + validateFundingSourceOptions(input.options) + if (input.synapse.chain.id !== filecoinMainnet.id) { + throw new Error('Squid source acquisition is available only for Filecoin Mainnet') + } + if (input.options.walletAddress != null || input.options.sessionKey != null || input.options.viewAddress != null) { + throw new Error('Squid source acquisition requires owner private-key auth') + } + + const path = markerPath() + const policy = sourcePolicy(input.options.fromChain) + const filFeeBuffer = policy.chain.id === filecoinMainnet.id && input.filShortfall > 0n ? policy.maxNativeFee : 0n + const destinationRequirements = requirements(input, filFeeBuffer) + const sourceRpcUrl = input.options.sourceRpcUrl as string + const integratorId = process.env.SQUID_INTEGRATOR_ID?.trim() || DEFAULT_SQUID_INTEGRATOR_ID + const squid = { integratorId, fetch: fetchWithTimeout } + + await mkdir(dirname(path), { recursive: true }) + try { + await writeFile( + path, + JSON.stringify({ + owner: input.owner, + sourceChain: policy.chain.id, + sourceToken: input.options.fromToken, + maxSourceAmount: input.options.maxSourceAmount, + createdAt: new Date().toISOString(), + targets: destinationRequirements.map((requirement) => ({ + token: requirement.token, + amount: requirement.amount.toString(), + })), + }), + { encoding: 'utf8', flag: 'wx', mode: 0o600 } + ) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') throw pendingError(path) + throw error + } + + let executionStarted = false + try { + const clients = makeSourceClients(policy, sourceRpcUrl, input.options.privateKey, input.owner) + const planned = await planSquidFunding( + { + owner: input.owner, + sourceChainId: policy.chain.id, + sourceToken: input.options.fromToken as string, + requirements: destinationRequirements, + maxSourceAmount: input.options.maxSourceAmount as string, + slippage: SLIPPAGE_PERCENT, + }, + squid + ) + let availableHeadroom = + planned.maxSourceAmount - planned.quotes.reduce((total, quote) => total + quote.sourceAmount, 0n) + if (availableHeadroom < 0n) availableHeadroom = 0n + const plan = { + ...planned, + quotes: planned.quotes.map((quote) => { + let headroom = (quote.sourceAmount * PRICE_DRIFT_HEADROOM_PERCENT + 99n) / 100n + if (headroom > availableHeadroom) headroom = availableHeadroom + availableHeadroom -= headroom + return { ...quote, sourceAmount: quote.sourceAmount + headroom } + }), + } + let sourceBalanceFloor = 0n + if (policy.chain.id === filecoinMainnet.id) { + if (plan.source.token.toLowerCase() === FILECOIN_USDFC.toLowerCase()) { + sourceBalanceFloor = input.requiredWalletUsdfc + } else if (plan.source.token === NATIVE_TOKEN_ADDRESS) { + sourceBalanceFloor = MIN_FIL_FOR_GAS + } + } + const nativeBalanceFloor = policy.chain.id === filecoinMainnet.id && input.filShortfall <= 0n ? MIN_FIL_FOR_GAS : 0n + + await input.confirm({ + source: plan.source, + sourceChainName: policy.chain.name, + quotes: plan.quotes, + maxSourceAmount: plan.maxSourceAmount, + maxNativeFee: policy.maxNativeFee, + nativeCurrency: policy.chain.nativeCurrency, + }) + executionStarted = true + await executeSquidFunding( + { + plan, + maxNativeFee: policy.maxNativeFee, + sourceBalanceFloor, + nativeBalanceFloor, + trustedTarget: SQUID_ROUTER, + trustedSpender: SQUID_ROUTER, + feeMode: policy.opStack === true ? 'op-stack' : 'standard', + ...(policy.opStack === true ? { opStackFeeBuffer: (fee: bigint) => (fee * 5n + 3n) / 4n } : {}), + maxPollAttempts: 120, + pollIntervalMs: 5_000, + }, + { + publicClient: clients.publicClient, + walletClient: clients.walletClient, + destinationClient: input.synapse.client as unknown as SquidPublicClient, + squid, + } + ) + await unlink(path) + } catch (error) { + if (!executionStarted) await unlink(path).catch(() => undefined) + if (error instanceof CliIncomplete) throw error + const privateKey = input.options.privateKey + throw new Error( + safeMessage(error, [ + sourceRpcUrl, + privateKey?.startsWith('0x') ? undefined : `0x${privateKey}`, + privateKey, + integratorId, + ]) + ) + } +} diff --git a/src/payments/types.ts b/src/payments/types.ts index 6e07518a..bf257a82 100644 --- a/src/payments/types.ts +++ b/src/payments/types.ts @@ -19,6 +19,14 @@ export interface PaymentSetupOptions extends CLIAuthOptions { rateAllowance: string } +/** Explicit source selection for interactive `payments fund`. */ +export interface FundingSourceOptions { + fromChain?: string + fromToken?: string + maxSourceAmount?: string + sourceRpcUrl?: string +} + export interface AutoFundOptions { /** Synapse instance (required) */ synapse: Synapse @@ -59,7 +67,7 @@ export interface FundingAdjustmentResult { warnings?: string[] } -export interface FundOptions extends CLIAuthOptions { +export interface FundOptions extends CLIAuthOptions, FundingSourceOptions { days?: number amount?: string /** diff --git a/src/test/unit/cli-options.test.ts b/src/test/unit/cli-options.test.ts index 67f9e255..f78ab785 100644 --- a/src/test/unit/cli-options.test.ts +++ b/src/test/unit/cli-options.test.ts @@ -1,5 +1,6 @@ import { Command, Option } from 'commander' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { paymentsCommand } from '../../commands/payments.js' import { serverCommand } from '../../commands/server.js' import { sessionCommand } from '../../commands/session.js' import { log } from '../../utils/cli-logger.js' @@ -162,6 +163,17 @@ describe('auth and context option env bindings', () => { }) }) +describe('Squid source options', () => { + it('adds the four source options only to payments fund', () => { + const sourceFlags = ['--from-chain', '--from-token', '--max-source-amount', '--source-rpc-url'] + const fund = paymentsCommand.commands.find((command) => command.name() === 'fund') + const setup = paymentsCommand.commands.find((command) => command.name() === 'setup') + + expect(sourceFlags.every((flag) => fund?.options.some((option) => option.long === flag))).toBe(true) + expect(sourceFlags.some((flag) => setup?.options.some((option) => option.long === flag))).toBe(false) + }) +}) + describe('validateAndNormalizeAutoFundOptions', () => { it('throws when --min-runway-days is set without --auto-fund', () => { expect(() => validateAndNormalizeAutoFundOptions({ minRunwayDays: 10 })).toThrow( diff --git a/src/test/unit/run-fund-squid.test.ts b/src/test/unit/run-fund-squid.test.ts new file mode 100644 index 00000000..bc212e2f --- /dev/null +++ b/src/test/unit/run-fund-squid.test.ts @@ -0,0 +1,206 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { runFund } from '../../payments/fund.js' + +const { mockAcquire, mockCalculate, mockConfirm, mockGetPaymentStatus, mockInitialize, mockInteractive, mockPlan } = + vi.hoisted(() => ({ + mockAcquire: vi.fn(), + mockCalculate: vi.fn(), + mockConfirm: vi.fn(), + mockGetPaymentStatus: vi.fn(), + mockInitialize: vi.fn(), + mockInteractive: vi.fn(() => true), + mockPlan: vi.fn(), + })) + +vi.mock('@clack/prompts', () => ({ confirm: mockConfirm, isCancel: vi.fn(() => false) })) +vi.mock('../../core/synapse/index.js', () => ({ + getClientAddress: vi.fn(() => '0x1111111111111111111111111111111111111111'), + initializeSynapse: mockInitialize, + mainnet: { id: 314, contracts: { usdfc: { address: '0x80B98d3aa09ffff255c3ba4A241111Ff1262F045' } } }, +})) +vi.mock('../../utils/cli-auth.js', () => ({ + parseCLIAuth: vi.fn(() => ({})), + getCLILogger: vi.fn(() => ({})), +})) +vi.mock('../../utils/cli-helpers.js', () => ({ + intro: vi.fn(), + outro: vi.fn(), + cancel: vi.fn(), + isInteractive: mockInteractive, + createSpinner: vi.fn(() => ({ start: vi.fn(), stop: vi.fn(), message: vi.fn() })), +})) +vi.mock('../../utils/cli-logger.js', () => ({ + isTTY: vi.fn(() => true), + log: { line: vi.fn(), indent: vi.fn(), flush: vi.fn(), section: vi.fn() }, +})) +vi.mock('../../core/payments/index.js', () => ({ + DEFAULT_LOCKUP_DAYS: 30, + MIN_FIL_FOR_GAS: 100n, + calculateFilecoinPayFundingPlan: mockCalculate, + getPaymentStatus: mockGetPaymentStatus, + planFilecoinPayFunding: mockPlan, + checkUSDFCBalance: vi.fn(), + depositUSDFC: vi.fn(), + withdrawUSDFC: vi.fn(), + clampDepositToLimit: vi.fn(), + executeFilecoinPayFunding: vi.fn(), + toStorageRunwaySummary: vi.fn(() => ({ state: 'no-spend' })), +})) +vi.mock('../../core/utils/format.js', () => ({ formatUSDFC: vi.fn((value: bigint) => String(value)) })) +vi.mock('../../core/utils/index.js', () => ({ formatRunwaySummary: vi.fn(() => ({ coverage: 'No spend' })) })) +vi.mock('../../payments/squid-funding.js', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, acquirePaymentShortfalls: mockAcquire } +}) + +const sourceOptions = { + amount: '1', + fromChain: 'ethereum', + fromToken: 'USDC', + maxSourceAmount: '2', + sourceRpcUrl: 'https://rpc.example', + privateKey: `0x${'01'.padStart(64, '0')}`, +} + +function refreshedPlan() { + return { + plan: { + targetType: 'deposit', + mode: 'exact', + delta: 0n, + targetDeposit: 1_000_000_000_000_000_000n, + projected: { runway: { state: 'no-spend' }, depositedBalance: 1_000_000_000_000_000_000n }, + current: { runway: { rateUsed: 0n } }, + }, + status: { walletUsdfcBalance: 1_000_000_000_000_000_000n }, + } +} + +describe('interactive Squid funding command', () => { + beforeEach(() => { + vi.clearAllMocks() + process.exitCode = 0 + mockInteractive.mockReset().mockReturnValue(true) + mockConfirm.mockReset().mockResolvedValue(true) + mockInitialize.mockResolvedValue({ + chain: { id: 314 }, + payments: { accountSummary: vi.fn(async () => ({ funds: 0n })) }, + }) + mockGetPaymentStatus.mockResolvedValue({ filBalance: 90n, walletUsdfcBalance: 2n }) + mockCalculate.mockReturnValue({ delta: 10n, current: { spendRatePerEpoch: 1n } }) + mockPlan.mockResolvedValue(refreshedPlan()) + mockAcquire.mockImplementation(async (options) => { + await options.confirm({ + source: { symbol: 'USDC', decimals: 6, chainId: 1 }, + sourceChainName: 'Ethereum', + maxSourceAmount: 4_000_000n, + maxNativeFee: 30_000_000_000_000_000n, + nativeCurrency: { symbol: 'ETH', decimals: 18 }, + quotes: [ + { + sourceAmount: 1_000_000n, + requirement: { id: 'filecoin-fil', amount: options.filShortfall }, + costs: [], + }, + { + sourceAmount: 2_000_000n, + requirement: { id: 'filecoin-usdfc', amount: options.usdfcShortfall }, + costs: [ + { + kind: 'fee', + amount: 1_500_000_000_000_000n, + token: { + address: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + chainId: 1, + }, + }, + { + kind: 'fee', + amount: 9_000_000_000_000_000n, + token: { chainId: 1 }, + }, + { + kind: 'fee', + amount: 9_000_000_000_000_000n, + token: { + address: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + chainId: 8453, + }, + }, + { + kind: 'gas', + amount: 9_000_000_000_000_000n, + token: { + address: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + chainId: 1, + }, + }, + ], + }, + ], + }) + }) + }) + + it('buffers the wallet shortfall for one hour of spend, confirms, and replans after acquisition', async () => { + await runFund(sourceOptions) + + expect(mockAcquire).toHaveBeenCalledWith( + expect.objectContaining({ + filShortfall: 10n, + usdfcShortfall: 128n, + requiredWalletUsdfc: 130n, + options: expect.objectContaining({ privateKey: sourceOptions.privateKey }), + }) + ) + expect(mockConfirm).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('Spend 3 USDC from Ethereum') }) + ) + expect(mockConfirm.mock.calls[0]?.[0].message).toContain('source-token limit: 4 USDC') + expect(mockConfirm.mock.calls[0]?.[0].message).toContain('estimated Squid route fee: 0.0015 ETH') + expect(mockConfirm.mock.calls[0]?.[0].message).toContain('transaction-gas limit: 0.03 ETH') + expect(mockPlan).toHaveBeenCalledOnce() + }) + + it('does not enter the acquisition adapter when the planner reports no shortfall', async () => { + mockGetPaymentStatus.mockResolvedValueOnce({ filBalance: 100n, walletUsdfcBalance: 10n }) + mockCalculate.mockReturnValueOnce({ delta: 0n, walletShortfall: 0n }) + mockInteractive.mockReturnValue(false) + + await runFund(sourceOptions) + + expect(mockAcquire).not.toHaveBeenCalled() + expect(mockPlan).toHaveBeenCalledOnce() + }) + + it('does not acquire FIL for a withdrawal', async () => { + mockCalculate.mockReturnValueOnce({ delta: -10n }) + + await runFund(sourceOptions) + + expect(mockAcquire).not.toHaveBeenCalled() + expect(mockPlan).toHaveBeenCalledOnce() + }) + + it('requires an interactive terminal before contacting Squid', async () => { + mockInteractive.mockReturnValueOnce(false) + + await expect(runFund(sourceOptions)).rejects.toThrow(/interactive terminal/) + expect(mockInitialize).toHaveBeenCalledOnce() + expect(mockAcquire).not.toHaveBeenCalled() + }) + + it('rejects incomplete source options before connecting', async () => { + await expect(runFund({ amount: '1', fromChain: 'arbitrum' })).rejects.toThrow(/requires --from-chain/) + expect(mockInitialize).not.toHaveBeenCalled() + }) + + it('treats declined source-spend confirmation as an incomplete operation', async () => { + mockConfirm.mockResolvedValueOnce(false) + + await runFund(sourceOptions) + + expect(process.exitCode).toBe(2) + expect(mockPlan).not.toHaveBeenCalled() + }) +}) diff --git a/src/test/unit/run-fund.test.ts b/src/test/unit/run-fund.test.ts index 0c740299..77778062 100644 --- a/src/test/unit/run-fund.test.ts +++ b/src/test/unit/run-fund.test.ts @@ -17,6 +17,10 @@ vi.mock('@clack/prompts', () => ({ vi.mock('../../core/synapse/index.js', () => ({ initializeSynapse: vi.fn(async () => ({})), })) +vi.mock('../../payments/squid-funding.js', () => ({ + acquirePaymentShortfalls: vi.fn(), + validateFundingSourceOptions: vi.fn(() => false), +})) vi.mock('../../utils/cli-auth.js', () => ({ parseCLIAuth: vi.fn(() => ({})), getCLILogger: vi.fn(() => ({})), diff --git a/src/test/unit/squid-funding.test.ts b/src/test/unit/squid-funding.test.ts new file mode 100644 index 00000000..fadbffb0 --- /dev/null +++ b/src/test/unit/squid-funding.test.ts @@ -0,0 +1,304 @@ +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { SquidFundingPlan } from '@filecoin-project/squid-evm-funding' +import type { Hex } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { acquirePaymentShortfalls, validateFundingSourceOptions } from '../../payments/squid-funding.js' + +const { mockExecute, mockPlan } = vi.hoisted(() => ({ + mockExecute: vi.fn(), + mockPlan: vi.fn(), +})) + +vi.mock('@filecoin-project/squid-evm-funding', () => ({ + NATIVE_TOKEN_ADDRESS: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + executeSquidFunding: mockExecute, + planSquidFunding: mockPlan, +})) + +const PRIVATE_KEY = `0x${'01'.padStart(64, '0')}` as Hex +const OWNER = privateKeyToAccount(PRIVATE_KEY).address +const CHAIN_IDS: Record = { + filecoin: 314, + arbitrum: 42161, + ethereum: 1, + base: 8453, + optimism: 10, + polygon: 137, + avalanche: 43114, + bnb: 56, +} + +let directory: string +let marker: string +const originalEnvironment = { + DATABASE_PATH: process.env.DATABASE_PATH, + SQUID_INTEGRATOR_ID: process.env.SQUID_INTEGRATOR_ID, + NETWORK: process.env.NETWORK, + RPC_URL: process.env.RPC_URL, +} + +function serializeErrorChain(error: unknown): string { + const chain: unknown[] = [] + let current = error + while (current instanceof Error) { + chain.push({ name: current.name, message: current.message, stack: current.stack }) + current = current.cause + } + return JSON.stringify(chain) +} + +function input(overrides: Record = {}) { + return { + synapse: { chain: { id: 314 }, client: {} } as never, + owner: OWNER, + filShortfall: 2n, + usdfcShortfall: 3n, + requiredWalletUsdfc: 8n, + options: { + fromChain: 'arbitrum', + fromToken: 'USDC', + maxSourceAmount: '10', + sourceRpcUrl: 'https://rpc.example/secret', + privateKey: PRIVATE_KEY, + }, + confirm: vi.fn(), + ...overrides, + } +} + +describe('Squid payment shortfalls', () => { + beforeEach(async () => { + vi.clearAllMocks() + directory = await mkdtemp(join(tmpdir(), 'filecoin-pin-squid-')) + marker = join(directory, 'squid-funding.pending') + process.env.DATABASE_PATH = join(directory, 'pins.db') + process.env.SQUID_INTEGRATOR_ID = 'test-integrator' + delete process.env.NETWORK + delete process.env.RPC_URL + + mockPlan.mockImplementation(async ({ owner, sourceChainId, requirements, maxSourceAmount }) => { + const plan: SquidFundingPlan = { + owner, + source: { + chainId: sourceChainId, + token: '0x1111111111111111111111111111111111111111', + symbol: 'USDC', + decimals: 6, + }, + quotes: requirements.map((requirement: SquidFundingPlan['quotes'][number]['requirement'], index: number) => ({ + id: `quote-${index}`, + requirement, + sourceAmount: 1_000_000n, + destinationAmount: requirement.amount, + actions: [], + costs: [], + })), + maxSourceAmount: BigInt(maxSourceAmount) * 1_000_000n, + slippage: 1, + } + return plan + }) + mockExecute.mockResolvedValue({ sourceAmount: 2_000_000n, nativeFee: 1n, routes: [] }) + }) + + afterEach(async () => { + for (const [key, value] of Object.entries(originalEnvironment)) { + if (value == null) delete process.env[key] + else process.env[key] = value + } + await rm(directory, { recursive: true, force: true }) + }) + + it('requires all four source options together', () => { + expect(validateFundingSourceOptions({})).toBe(false) + expect(() => validateFundingSourceOptions({ fromChain: 'arbitrum' })).toThrow(/requires --from-chain/) + expect( + validateFundingSourceOptions({ + fromChain: 'arbitrum', + fromToken: 'USDC', + maxSourceAmount: '1', + sourceRpcUrl: 'https://rpc.example', + }) + ).toBe(true) + }) + + it('passes exact FIL and USDFC shortfalls and removes the marker after verified execution', async () => { + await expect(acquirePaymentShortfalls(input())).resolves.toBeUndefined() + + const planned = mockPlan.mock.calls[0]?.[0] + expect(planned.requirements.map((requirement: { amount: bigint }) => requirement.amount)).toEqual([2n, 3n]) + expect(mockPlan.mock.calls[0]?.[1]).toMatchObject({ integratorId: 'test-integrator' }) + expect(mockExecute).toHaveBeenCalledOnce() + await expect(stat(marker)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('uses the built-in public Squid integrator ID when no override is set', async () => { + delete process.env.SQUID_INTEGRATOR_ID + + await acquirePaymentShortfalls(input()) + + expect(mockPlan.mock.calls[0]?.[1]).toMatchObject({ + integratorId: 'filecoin-testing-94a4a25a-d40b-41cb-b148-e96098862', + }) + }) + + it('adds one percent price headroom without exceeding the source cap', async () => { + const defaultPlan = mockPlan.getMockImplementation() + if (defaultPlan == null) throw new Error('Missing default plan mock') + mockPlan.mockImplementationOnce(async (request) => ({ + ...(await defaultPlan(request)), + maxSourceAmount: 2_010_000n, + })) + + await acquirePaymentShortfalls(input()) + + const plan = mockExecute.mock.calls[0]?.[0].plan + expect(plan.quotes.map((quote: { sourceAmount: bigint }) => quote.sourceAmount)).toEqual([1_010_000n, 1_000_000n]) + expect(plan.quotes.reduce((total: bigint, quote: { sourceAmount: bigint }) => total + quote.sourceAmount, 0n)).toBe( + plan.maxSourceAmount + ) + }) + + it('leaves a private marker when execution fails and sanitizes secrets', async () => { + const privateKey = PRIVATE_KEY.slice(2) + const rpcUrl = input().options.sourceRpcUrl + const integratorId = process.env.SQUID_INTEGRATOR_ID as string + mockExecute.mockRejectedValueOnce( + new Error(`failed ${PRIVATE_KEY} or ${privateKey} at ${rpcUrl} for ${integratorId}`) + ) + + let failure: unknown + try { + await acquirePaymentShortfalls(input({ options: { ...input().options, privateKey } })) + } catch (error) { + failure = error + } + const serialized = serializeErrorChain(failure) + expect(failure).toBeInstanceOf(Error) + expect((failure as Error).message).toContain('[redacted]') + for (const secret of [PRIVATE_KEY, privateKey, rpcUrl, integratorId]) { + expect(serialized).not.toContain(secret) + } + if (process.platform !== 'win32') expect((await stat(marker)).mode & 0o777).toBe(0o600) + expect(JSON.parse(await readFile(marker, 'utf8'))).toMatchObject({ + owner: OWNER, + sourceChain: 42161, + sourceToken: 'USDC', + maxSourceAmount: '10', + }) + }) + + it('uses atomic marker creation to block an ambiguous rerun', async () => { + await writeFile(marker, '{}', { mode: 0o600 }) + const request = input() + + await expect(acquirePaymentShortfalls(request)).rejects.toThrow(marker) + expect(mockPlan).not.toHaveBeenCalled() + expect(request.confirm).not.toHaveBeenCalled() + expect(mockExecute).not.toHaveBeenCalled() + }) + + it('removes the marker when confirmation fails before execution', async () => { + await expect( + acquirePaymentShortfalls( + input({ + confirm: vi.fn(async () => { + throw new Error('declined') + }), + }) + ) + ).rejects.toThrow('declined') + + await expect(stat(marker)).rejects.toMatchObject({ code: 'ENOENT' }) + expect(mockExecute).not.toHaveBeenCalled() + }) + + it('does nothing when there is no shortfall', async () => { + await expect(acquirePaymentShortfalls(input({ filShortfall: 0n, usdfcShortfall: 0n }))).resolves.toBeUndefined() + expect(mockPlan).not.toHaveBeenCalled() + expect(mockExecute).not.toHaveBeenCalled() + }) + + it('rejects a different signing owner before contacting Squid', async () => { + await expect( + acquirePaymentShortfalls(input({ owner: '0x2222222222222222222222222222222222222222' })) + ).rejects.toThrow(/must control the Filecoin payment owner/) + expect(mockPlan).not.toHaveBeenCalled() + }) + + it('owns Mainnet and private-key authentication checks in the adapter', async () => { + await expect( + acquirePaymentShortfalls(input({ synapse: { chain: { id: 314159 }, client: {} } as never })) + ).rejects.toThrow(/only for Filecoin Mainnet/) + await expect( + acquirePaymentShortfalls(input({ options: { ...input().options, walletAddress: OWNER } })) + ).rejects.toThrow(/owner private-key auth/) + expect(mockPlan).not.toHaveBeenCalled() + }) + + it('wires all eight source-chain policies and OP Stack fee accounting', async () => { + for (const [fromChain, chainId] of Object.entries(CHAIN_IDS)) { + const request = input({ options: { ...input().options, fromChain } }) + await acquirePaymentShortfalls(request) + expect(mockPlan).toHaveBeenLastCalledWith( + expect.objectContaining({ sourceChainId: chainId, sourceToken: 'USDC' }), + expect.anything() + ) + const execution = mockExecute.mock.calls.at(-1)?.[0] + expect(execution.feeMode).toBe(fromChain === 'base' || fromChain === 'optimism' ? 'op-stack' : 'standard') + if (execution.feeMode === 'op-stack') expect(execution.opStackFeeBuffer(4n)).toBe(5n) + if (fromChain === 'ethereum') { + expect(request.confirm).toHaveBeenCalledWith( + expect.objectContaining({ + maxNativeFee: 30_000_000_000_000_000n, + nativeCurrency: expect.objectContaining({ symbol: 'ETH', decimals: 18 }), + }) + ) + } + } + }) + + it('allows Filecoin USDFC to acquire a FIL shortfall without spending reserved USDFC', async () => { + mockPlan.mockResolvedValueOnce({ + owner: OWNER, + source: { + chainId: 314, + token: '0x80B98d3aa09ffff255c3ba4A241111Ff1262F045', + symbol: 'USDFC', + decimals: 18, + }, + quotes: [], + maxSourceAmount: 10n, + slippage: 1, + }) + await acquirePaymentShortfalls(input({ options: { ...input().options, fromChain: 'filecoin' } })) + expect(mockPlan.mock.calls[0]?.[0].requirements[0].amount).toBe(30_000_000_000_000_002n) + expect(mockExecute.mock.calls[0]?.[0]).toMatchObject({ + sourceBalanceFloor: 8n, + nativeBalanceFloor: 0n, + }) + }) + + it('preserves the Filecoin FIL reserve when FIL funds a USDFC-only shortfall', async () => { + mockPlan.mockResolvedValueOnce({ + owner: OWNER, + source: { + chainId: 314, + token: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + symbol: 'FIL', + decimals: 18, + }, + quotes: [], + maxSourceAmount: 10n, + slippage: 1, + }) + await acquirePaymentShortfalls(input({ filShortfall: 0n, options: { ...input().options, fromChain: 'filecoin' } })) + expect(mockExecute.mock.calls[0]?.[0]).toMatchObject({ + sourceBalanceFloor: 100_000_000_000_000_000n, + nativeBalanceFloor: 100_000_000_000_000_000n, + }) + }) +}) diff --git a/src/utils/cli-options.ts b/src/utils/cli-options.ts index bc803c8f..8fa9bb21 100644 --- a/src/utils/cli-options.ts +++ b/src/utils/cli-options.ts @@ -29,6 +29,19 @@ export function rpcUrlOption(description: string): Option { return new Option('--rpc-url ', description).env('RPC_URL') } +/** Add the source token controls used only by interactive `payments fund`. */ +export function addFundingSourceOptions(command: Command): Command { + return command + .option('--from-chain ', 'Source EVM chain') + .option('--from-token ', 'Squid source token symbol, address, or "native"') + .option('--max-source-amount ', 'Maximum source-token amount to spend') + .option('--source-rpc-url ', 'Source-chain RPC endpoint') + .addHelpText( + 'after', + '\nSquid funding requires Filecoin Mainnet, an interactive terminal, and owner private-key auth.\n' + ) +} + /** * Add the signing-auth flags shared by every authenticated command: * `--private-key`, `--wallet-address`, `--session-key`.