From 9c2d23e2a2b9f821e4e7e675f071eebeb5b572fa Mon Sep 17 00:00:00 2001 From: Mikers Date: Wed, 29 Jul 2026 17:20:24 -1000 Subject: [PATCH 01/26] feat(payments): add interactive Squid funding --- README.md | 18 ++ documentation/glossary.md | 3 + package.json | 1 + pnpm-lock.yaml | 17 ++ pnpm-workspace.yaml | 1 + src/commands/payments.ts | 3 +- src/payments/fund.ts | 72 ++++++- src/payments/squid-funding.ts | 304 +++++++++++++++++++++++++++ src/payments/types.ts | 10 +- src/test/unit/cli-options.test.ts | 12 ++ src/test/unit/run-fund-squid.test.ts | 192 +++++++++++++++++ src/test/unit/run-fund.test.ts | 5 + src/test/unit/squid-funding.test.ts | 198 +++++++++++++++++ src/utils/cli-options.ts | 13 ++ 14 files changed, 840 insertions(+), 9 deletions(-) create mode 100644 src/payments/squid-funding.ts create mode 100644 src/test/unit/run-fund-squid.test.ts create mode 100644 src/test/unit/squid-funding.test.ts diff --git a/README.md b/README.md index e06a9948..4f1aeaf6 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,21 @@ 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 +export SQUID_INTEGRATOR_ID=your-integrator-id +filecoin-pin payments fund --days 30 \ + --from-chain arbitrum \ + --from-token USDC \ + --max-source-amount 10 \ + --source-rpc-url https://your-arbitrum-rpc.example +``` + +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 +290,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=... # Squid-issued integration identifier + # 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 770c6507..de6e85c8 100644 --- a/package.json +++ b/package.json @@ -152,6 +152,7 @@ "picocolors": "^1.1.1", "pino": "^10.3.1", "semver": "^7.7.4", + "squid-evm-funding": "github:snissn/squid-evm-funding#ad4c7f007637d9ae2ba2f2bdb48cf9f7beea01b6", "varint": "^6.0.0", "viem": "^2.48.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f7f0c84d..865dc780 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,6 +92,9 @@ importers: semver: specifier: ^7.7.4 version: 7.8.5 + squid-evm-funding: + specifier: github:snissn/squid-evm-funding#ad4c7f007637d9ae2ba2f2bdb48cf9f7beea01b6 + version: https://codeload.github.com/snissn/squid-evm-funding/tar.gz/ad4c7f007637d9ae2ba2f2bdb48cf9f7beea01b6(typescript@6.0.3)(zod@4.3.6) varint: specifier: ^6.0.0 version: 6.0.0 @@ -2114,6 +2117,11 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + squid-evm-funding@https://codeload.github.com/snissn/squid-evm-funding/tar.gz/ad4c7f007637d9ae2ba2f2bdb48cf9f7beea01b6: + resolution: {tarball: https://codeload.github.com/snissn/squid-evm-funding/tar.gz/ad4c7f007637d9ae2ba2f2bdb48cf9f7beea01b6} + version: 0.1.0 + engines: {node: '>=24'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -4655,6 +4663,15 @@ snapshots: split2@4.2.0: {} + squid-evm-funding@https://codeload.github.com/snissn/squid-evm-funding/tar.gz/ad4c7f007637d9ae2ba2f2bdb48cf9f7beea01b6(typescript@6.0.3)(zod@4.3.6): + dependencies: + viem: 2.48.4(typescript@6.0.3)(zod@4.3.6) + transitivePeerDependencies: + - bufferutil + - typescript + - utf-8-validate + - zod + stackback@0.0.2: {} std-env@4.0.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6ef090de..641bd729 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,3 +8,4 @@ onlyBuiltDependencies: - esbuild - node-datachannel - playwright + - 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..87ec784e 100644 --- a/src/payments/fund.ts +++ b/src/payments/fund.ts @@ -7,27 +7,35 @@ import { confirm, isCancel } from '@clack/prompts' import type { Synapse } 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, mainnet } 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, + isFundingSourceRequested, + 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 +253,14 @@ export async function runFund(options: FundOptions): Promise { spinner.start('Connecting...') try { + const sourceRequested = isFundingSourceRequested(options) + 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 +275,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 usdfcShortfall = preview.delta > 0n ? (preview.walletShortfall ?? 0n) : 0n + if (filShortfall > 0n || usdfcShortfall > 0n) { + if (!isInteractive()) throw new Error('Squid source acquisition requires an interactive terminal') + if (synapse.chain.id !== mainnet.id) { + throw new Error('Squid source acquisition is available only for Filecoin Mainnet') + } + if ( + options.privateKey == null || + options.privateKey.trim() === '' || + options.walletAddress != null || + options.sessionKey != null || + options.viewAddress != null + ) { + throw new Error('Squid source acquisition requires owner private-key auth') + } + await acquirePaymentShortfalls({ + synapse, + owner: getClientAddress(synapse), + filShortfall, + usdfcShortfall, + requiredWalletUsdfc: preview.delta > 0n ? preview.delta : 0n, + options, + confirm: async (summary) => { + const spend = summary.quotes.reduce((total, quote) => total + quote.sourceAmount, 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.source.chain.networkName} via Squid to receive ${targets} on Filecoin (source cap: ${formatUnits(summary.maxSourceAmount, summary.source.decimals)} ${summary.source.symbol})?`, + 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..c2fb41ec --- /dev/null +++ b/src/payments/squid-funding.ts @@ -0,0 +1,304 @@ +import { access, mkdir, unlink, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import type { Synapse } from '@filoz/synapse-sdk' +import { + type DestinationRequirement, + executeSquidFunding, + fetchSquidCatalog, + NATIVE_TOKEN_ADDRESS, + planSquidFunding, + quoteSquidRoute, + resolveSourceToken, + type SourceToken, + type SquidPublicClient, + type SquidQuote, + type SquidWalletClient, +} from 'squid-evm-funding' +import { + type Address, + type Chain, + createPublicClient, + createWalletClient, + getAddress, + type Hex, + http, + parseUnits, +} 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 { FundingSourceOptions } from './types.js' + +const SQUID_ROUTER = getAddress('0xce16F69375520ab01377ce7B88f5BA8C48F8D666') +const FILECOIN_USDFC = filecoinMainnet.contracts.usdfc.address +const REQUEST_TIMEOUT_MS = 15_000 +const SLIPPAGE_PERCENT = 1 + +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 + quotes: readonly SquidQuote[] + maxSourceAmount: bigint +} + +export interface AcquirePaymentShortfallsInput { + synapse: Synapse + owner: Address + filShortfall: bigint + usdfcShortfall: bigint + /** Wallet USDFC reserved for the later Filecoin Pay deposit. */ + requiredWalletUsdfc: bigint + options: FundingSourceOptions & { privateKey?: string | undefined } + confirm: (summary: PaymentAcquisitionSummary) => Promise +} + +export function isFundingSourceRequested(options: FundingSourceOptions): boolean { + return [options.fromChain, options.fromToken, options.maxSourceAmount, options.sourceRpcUrl].some( + (value) => value != null && value.trim() !== '' + ) +} + +export function validateFundingSourceOptions(options: FundingSourceOptions): void { + if (!isFundingSourceRequested(options)) return + 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' + ) + } +} + +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): DestinationRequirement[] { + return [ + ...(input.filShortfall > 0n + ? [ + { + id: 'filecoin-fil', + chainId: filecoinMainnet.id, + token: NATIVE_TOKEN_ADDRESS, + amount: input.filShortfall, + 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.` + ) +} + +async function assertNoPendingMarker(path: string): Promise { + try { + await access(path) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return + throw error + } + throw pendingError(path) +} + +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 { + const destinationRequirements = requirements(input) + if (destinationRequirements.length === 0) return false + validateFundingSourceOptions(input.options) + if (input.synapse.chain.id !== filecoinMainnet.id) { + throw new Error('Squid source acquisition is available only for Filecoin Mainnet') + } + + const path = markerPath() + await assertNoPendingMarker(path) + + const policy = sourcePolicy(input.options.fromChain) + const sourceRpcUrl = input.options.sourceRpcUrl as string + const integratorId = process.env.SQUID_INTEGRATOR_ID ?? '' + if (integratorId.trim() === '') throw new Error('SQUID_INTEGRATOR_ID is required') + const squid = { integratorId, fetch: fetchWithTimeout } + + try { + const clients = makeSourceClients(policy, sourceRpcUrl, input.options.privateKey, input.owner) + const catalog = await fetchSquidCatalog(squid) + const source = resolveSourceToken(catalog, policy.chain.id, input.options.fromToken as string) + const maxSourceAmount = parseUnits(input.options.maxSourceAmount as string, source.decimals) + if (maxSourceAmount <= 0n) throw new Error('--max-source-amount must be greater than zero') + const quotes = await planSquidFunding( + { + owner: input.owner, + source, + requirements: destinationRequirements, + maxSourceAmount, + slippage: SLIPPAGE_PERCENT, + }, + squid + ) + const sourceBalanceFloor = + policy.chain.id === filecoinMainnet.id && source.token.toLowerCase() === FILECOIN_USDFC.toLowerCase() + ? input.requiredWalletUsdfc + : policy.chain.id === filecoinMainnet.id && source.native + ? MIN_FIL_FOR_GAS + : 0n + const nativeBalanceFloor = policy.chain.id === filecoinMainnet.id ? MIN_FIL_FOR_GAS : 0n + + await input.confirm({ source, quotes, maxSourceAmount }) + await mkdir(dirname(path), { recursive: true }) + try { + await writeFile( + path, + JSON.stringify({ + owner: input.owner, + sourceChain: source.chain.chainId, + sourceToken: source.token, + maxSourceAmount: maxSourceAmount.toString(), + 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 + } + + await executeSquidFunding( + { + account: clients.account.address, + source, + quotes, + maxSourceAmount, + 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: (chainId) => { + if (chainId !== filecoinMainnet.id) throw new Error(`Unsupported destination chain ${chainId}`) + return input.synapse.client as unknown as SquidPublicClient + }, + refreshQuote: (quote) => + quoteSquidRoute( + { + owner: input.owner, + source: quote.source, + requirement: quote.requirement, + sourceAmount: quote.sourceAmount, + slippage: SLIPPAGE_PERCENT, + }, + squid + ), + squidStatusOptions: squid, + } + ) + await unlink(path) + return true + } catch (error) { + 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, + ]), + { + cause: error instanceof Error ? error : undefined, + } + ) + } +} 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..09c8f58e --- /dev/null +++ b/src/test/unit/run-fund-squid.test.ts @@ -0,0 +1,192 @@ +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 }, +})) +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', () => ({ + acquirePaymentShortfalls: mockAcquire, + isFundingSourceRequested: (options: Record) => + [options.fromChain, options.fromToken, options.maxSourceAmount, options.sourceRpcUrl].some( + (value) => value != null && value.trim() !== '' + ), + validateFundingSourceOptions: (options: Record) => { + const values = [options.fromChain, options.fromToken, options.maxSourceAmount, options.sourceRpcUrl] + if (values.some((value) => value != null && value.trim() !== '') && values.some((value) => !value?.trim())) { + throw new Error( + 'Source acquisition requires --from-chain, --from-token, --max-source-amount, and --source-rpc-url together' + ) + } + }, +})) + +const sourceOptions = { + amount: '1', + fromChain: 'arbitrum', + 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, walletShortfall: 8n }) + mockPlan.mockResolvedValue(refreshedPlan()) + mockAcquire.mockImplementation(async (options) => { + await options.confirm({ + source: { symbol: 'USDC', decimals: 6, chain: { networkName: 'Arbitrum' } }, + maxSourceAmount: 4_000_000n, + quotes: [ + { + sourceAmount: 1_000_000n, + requirement: { id: 'filecoin-fil', amount: options.filShortfall }, + }, + { + sourceAmount: 2_000_000n, + requirement: { id: 'filecoin-usdfc', amount: options.usdfcShortfall }, + }, + ], + }) + return true + }) + }) + + it('passes the exact planner shortfalls, confirms, and replans after acquisition', async () => { + await runFund(sourceOptions) + + expect(mockAcquire).toHaveBeenCalledWith( + expect.objectContaining({ + filShortfall: 10n, + usdfcShortfall: 8n, + requiredWalletUsdfc: 10n, + options: expect.objectContaining({ privateKey: sourceOptions.privateKey }), + }) + ) + expect(mockConfirm).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('Spend 3 USDC from Arbitrum') }) + ) + expect(mockConfirm.mock.calls[0]?.[0].message).toContain('source cap: 4 USDC') + 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('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('fails explicit Calibration selection before contacting Squid', async () => { + mockInitialize.mockResolvedValueOnce({ + chain: { id: 314159 }, + payments: { accountSummary: vi.fn(async () => ({ funds: 0n })) }, + }) + await expect(runFund({ ...sourceOptions, network: 'calibration' })).rejects.toThrow(/only for Filecoin Mainnet/) + expect(mockAcquire).not.toHaveBeenCalled() + }) + + it('fails a probed non-Mainnet RPC before payment or Squid calls', async () => { + mockInitialize.mockResolvedValueOnce({ + chain: { id: 314159 }, + payments: { accountSummary: vi.fn(async () => ({ funds: 0n })) }, + }) + + await expect(runFund(sourceOptions)).rejects.toThrow(/only for Filecoin Mainnet/) + expect(mockGetPaymentStatus).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..fffa2e58 100644 --- a/src/test/unit/run-fund.test.ts +++ b/src/test/unit/run-fund.test.ts @@ -17,6 +17,11 @@ vi.mock('@clack/prompts', () => ({ vi.mock('../../core/synapse/index.js', () => ({ initializeSynapse: vi.fn(async () => ({})), })) +vi.mock('../../payments/squid-funding.js', () => ({ + acquirePaymentShortfalls: vi.fn(), + isFundingSourceRequested: vi.fn(() => false), + validateFundingSourceOptions: vi.fn(), +})) 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..58b46904 --- /dev/null +++ b/src/test/unit/squid-funding.test.ts @@ -0,0 +1,198 @@ +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +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, mockFetchCatalog, mockPlan, mockQuote, mockResolve } = vi.hoisted(() => ({ + mockExecute: vi.fn(), + mockFetchCatalog: vi.fn(), + mockPlan: vi.fn(), + mockQuote: vi.fn(), + mockResolve: vi.fn(), +})) + +vi.mock('squid-evm-funding', () => ({ + NATIVE_TOKEN_ADDRESS: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + executeSquidFunding: mockExecute, + fetchSquidCatalog: mockFetchCatalog, + planSquidFunding: mockPlan, + quoteSquidRoute: mockQuote, + resolveSourceToken: mockResolve, +})) + +const PRIVATE_KEY = `0x${'01'.padStart(64, '0')}` as Hex +const OWNER = privateKeyToAccount(PRIVATE_KEY).address +const ROUTER = '0xce16F69375520ab01377ce7B88f5BA8C48F8D666' +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 = { + databasePath: process.env.DATABASE_PATH, + integratorId: process.env.SQUID_INTEGRATOR_ID, + network: process.env.NETWORK, + rpcUrl: process.env.RPC_URL, +} + +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 + + mockFetchCatalog.mockResolvedValue({}) + mockResolve.mockImplementation((_catalog, chainId) => ({ + chain: { chainId, networkName: 'source' }, + token: '0x1111111111111111111111111111111111111111', + symbol: 'USDC', + decimals: 6, + native: false, + })) + mockPlan.mockImplementation(async ({ source, requirements }) => + requirements.map((requirement: Record, index: number) => ({ + id: `quote-${index}`, + requirement, + source, + sourceAmount: 1_000_000n, + destinationAmount: requirement.amount, + target: ROUTER, + data: '0x12', + value: 0n, + gasLimit: 1n, + expiresAt: 2_000_000_000, + })) + ) + mockExecute.mockResolvedValue({ sourceAmount: 2_000_000n, nativeFee: 1n, routes: [] }) + }) + + afterEach(async () => { + for (const [name, value] of Object.entries(originalEnvironment)) { + const key = + name === 'databasePath' + ? 'DATABASE_PATH' + : name === 'integratorId' + ? 'SQUID_INTEGRATOR_ID' + : name === 'network' + ? 'NETWORK' + : 'RPC_URL' + 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({ fromChain: 'arbitrum' })).toThrow(/requires --from-chain/) + expect(() => + validateFundingSourceOptions({ + fromChain: 'arbitrum', + fromToken: 'USDC', + maxSourceAmount: '1', + sourceRpcUrl: 'https://rpc.example', + }) + ).not.toThrow() + }) + + it('passes exact FIL and USDFC shortfalls and removes the marker after verified execution', async () => { + await expect(acquirePaymentShortfalls(input())).resolves.toBe(true) + + const planned = mockPlan.mock.calls[0]?.[0] + expect(planned.requirements.map((requirement: { amount: bigint }) => requirement.amount)).toEqual([2n, 3n]) + expect(mockExecute).toHaveBeenCalledOnce() + await expect(stat(marker)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('leaves a mode-0600 marker when execution fails and sanitizes secrets', async () => { + mockExecute.mockRejectedValueOnce(new Error(`failed ${PRIVATE_KEY} at https://rpc.example/secret`)) + + await expect( + acquirePaymentShortfalls(input({ options: { ...input().options, privateKey: PRIVATE_KEY.slice(2) } })) + ).rejects.toThrow('failed [redacted] at [redacted]') + expect((await stat(marker)).mode & 0o777).toBe(0o600) + expect(JSON.parse(await readFile(marker, 'utf8'))).toMatchObject({ + owner: OWNER, + sourceChain: 42161, + maxSourceAmount: '10000000', + }) + }) + + it('refuses a pending marker before contacting Squid', async () => { + await writeFile(marker, '{}', { mode: 0o600 }) + + await expect(acquirePaymentShortfalls(input())).rejects.toThrow(marker) + expect(mockFetchCatalog).not.toHaveBeenCalled() + }) + + it('does nothing when there is no shortfall', async () => { + await expect(acquirePaymentShortfalls(input({ filShortfall: 0n, usdfcShortfall: 0n }))).resolves.toBe(false) + expect(mockFetchCatalog).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(mockFetchCatalog).not.toHaveBeenCalled() + }) + + it('wires all eight source-chain policies and OP Stack fee accounting', async () => { + for (const [fromChain, chainId] of Object.entries(CHAIN_IDS)) { + await acquirePaymentShortfalls(input({ options: { ...input().options, fromChain } })) + expect(mockResolve).toHaveBeenLastCalledWith({}, chainId, 'USDC') + 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) + } + }) + + it('preserves the Filecoin FIL and USDFC reserves when Filecoin is the source', async () => { + mockResolve.mockReturnValueOnce({ + chain: { chainId: 314, networkName: 'filecoin' }, + token: '0x80B98d3aa09ffff255c3ba4A241111Ff1262F045', + symbol: 'USDFC', + decimals: 18, + native: false, + }) + await acquirePaymentShortfalls(input({ options: { ...input().options, fromChain: 'filecoin' } })) + expect(mockExecute.mock.calls[0]?.[0]).toMatchObject({ + sourceBalanceFloor: 8n, + nativeBalanceFloor: 100_000_000_000_000_000n, + }) + }) +}) diff --git a/src/utils/cli-options.ts b/src/utils/cli-options.ts index bc803c8f..40b929b6 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, owner private-key auth, and SQUID_INTEGRATOR_ID.\n' + ) +} + /** * Add the signing-auth flags shared by every authenticated command: * `--private-key`, `--wallet-address`, `--session-key`. From 5772edb415e2c4e7610f8bf40a77f3754c57a6da Mon Sep 17 00:00:00 2001 From: Mikers Date: Wed, 29 Jul 2026 17:28:45 -1000 Subject: [PATCH 02/26] fix(payments): seal Squid error redaction --- src/payments/squid-funding.ts | 5 +---- src/test/unit/squid-funding.test.ts | 32 +++++++++++++++++++++++++---- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/payments/squid-funding.ts b/src/payments/squid-funding.ts index c2fb41ec..87a2dd7d 100644 --- a/src/payments/squid-funding.ts +++ b/src/payments/squid-funding.ts @@ -295,10 +295,7 @@ export async function acquirePaymentShortfalls(input: AcquirePaymentShortfallsIn privateKey?.startsWith('0x') ? undefined : `0x${privateKey}`, privateKey, integratorId, - ]), - { - cause: error instanceof Error ? error : undefined, - } + ]) ) } } diff --git a/src/test/unit/squid-funding.test.ts b/src/test/unit/squid-funding.test.ts index 58b46904..ec4ed6c9 100644 --- a/src/test/unit/squid-funding.test.ts +++ b/src/test/unit/squid-funding.test.ts @@ -46,6 +46,16 @@ const originalEnvironment = { rpcUrl: 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, @@ -138,11 +148,25 @@ describe('Squid payment shortfalls', () => { }) it('leaves a mode-0600 marker when execution fails and sanitizes secrets', async () => { - mockExecute.mockRejectedValueOnce(new Error(`failed ${PRIVATE_KEY} at https://rpc.example/secret`)) + 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}`) + ) - await expect( - acquirePaymentShortfalls(input({ options: { ...input().options, privateKey: PRIVATE_KEY.slice(2) } })) - ).rejects.toThrow('failed [redacted] at [redacted]') + 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) + } expect((await stat(marker)).mode & 0o777).toBe(0o600) expect(JSON.parse(await readFile(marker, 'utf8'))).toMatchObject({ owner: OWNER, From 82d085875a647b08f14d9e6a72cd2e7071a60302 Mon Sep 17 00:00:00 2001 From: Mikers Date: Wed, 29 Jul 2026 17:30:39 -1000 Subject: [PATCH 03/26] fix(payments): disclose Squid fee commitment --- src/payments/fund.ts | 8 ++------ src/payments/squid-funding.ts | 10 +++++++++- src/test/unit/run-fund-squid.test.ts | 13 +++++++++---- src/test/unit/squid-funding.test.ts | 11 ++++++++++- 4 files changed, 30 insertions(+), 12 deletions(-) diff --git a/src/payments/fund.ts b/src/payments/fund.ts index 87ec784e..2436eee8 100644 --- a/src/payments/fund.ts +++ b/src/payments/fund.ts @@ -31,11 +31,7 @@ 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, - isFundingSourceRequested, - validateFundingSourceOptions, -} from './squid-funding.js' +import { acquirePaymentShortfalls, isFundingSourceRequested, validateFundingSourceOptions } from './squid-funding.js' import type { AutoFundOptions, FundingAdjustmentResult, FundOptions } from './types.js' // Helper: confirm/warn or bail when target implies < lockup-days runway @@ -322,7 +318,7 @@ export async function runFund(options: FundOptions): Promise { ) .join(' and ') const proceed = await confirm({ - message: `Spend ${formatUnits(spend, summary.source.decimals)} ${summary.source.symbol} from ${summary.source.chain.networkName} via Squid to receive ${targets} on Filecoin (source cap: ${formatUnits(summary.maxSourceAmount, summary.source.decimals)} ${summary.source.symbol})?`, + message: `Spend ${formatUnits(spend, summary.source.decimals)} ${summary.source.symbol} from ${summary.source.chain.networkName} via Squid to receive ${targets} on Filecoin (source-token cap: ${formatUnits(summary.maxSourceAmount, summary.source.decimals)} ${summary.source.symbol}; additional network-fee commitment cap: ${formatUnits(summary.maxNativeFee, summary.nativeCurrency.decimals)} ${summary.nativeCurrency.symbol}, not a guaranteed final debit)?`, initialValue: false, }) if (isCancel(proceed) || !proceed) throw new CliIncomplete('Source acquisition cancelled by user') diff --git a/src/payments/squid-funding.ts b/src/payments/squid-funding.ts index 87a2dd7d..d790051a 100644 --- a/src/payments/squid-funding.ts +++ b/src/payments/squid-funding.ts @@ -61,6 +61,8 @@ interface PaymentAcquisitionSummary { source: SourceToken quotes: readonly SquidQuote[] maxSourceAmount: bigint + maxNativeFee: bigint + nativeCurrency: { symbol: string; decimals: number } } export interface AcquirePaymentShortfallsInput { @@ -224,7 +226,13 @@ export async function acquirePaymentShortfalls(input: AcquirePaymentShortfallsIn : 0n const nativeBalanceFloor = policy.chain.id === filecoinMainnet.id ? MIN_FIL_FOR_GAS : 0n - await input.confirm({ source, quotes, maxSourceAmount }) + await input.confirm({ + source, + quotes, + maxSourceAmount, + maxNativeFee: policy.maxNativeFee, + nativeCurrency: policy.chain.nativeCurrency, + }) await mkdir(dirname(path), { recursive: true }) try { await writeFile( diff --git a/src/test/unit/run-fund-squid.test.ts b/src/test/unit/run-fund-squid.test.ts index 09c8f58e..42cd8ce4 100644 --- a/src/test/unit/run-fund-squid.test.ts +++ b/src/test/unit/run-fund-squid.test.ts @@ -66,7 +66,7 @@ vi.mock('../../payments/squid-funding.js', () => ({ const sourceOptions = { amount: '1', - fromChain: 'arbitrum', + fromChain: 'ethereum', fromToken: 'USDC', maxSourceAmount: '2', sourceRpcUrl: 'https://rpc.example', @@ -102,8 +102,10 @@ describe('interactive Squid funding command', () => { mockPlan.mockResolvedValue(refreshedPlan()) mockAcquire.mockImplementation(async (options) => { await options.confirm({ - source: { symbol: 'USDC', decimals: 6, chain: { networkName: 'Arbitrum' } }, + source: { symbol: 'USDC', decimals: 6, chain: { networkName: 'Ethereum' } }, maxSourceAmount: 4_000_000n, + maxNativeFee: 30_000_000_000_000_000n, + nativeCurrency: { symbol: 'ETH', decimals: 18 }, quotes: [ { sourceAmount: 1_000_000n, @@ -131,9 +133,12 @@ describe('interactive Squid funding command', () => { }) ) expect(mockConfirm).toHaveBeenCalledWith( - expect.objectContaining({ message: expect.stringContaining('Spend 3 USDC from Arbitrum') }) + expect.objectContaining({ message: expect.stringContaining('Spend 3 USDC from Ethereum') }) + ) + expect(mockConfirm.mock.calls[0]?.[0].message).toContain('source-token cap: 4 USDC') + expect(mockConfirm.mock.calls[0]?.[0].message).toContain( + 'additional network-fee commitment cap: 0.03 ETH, not a guaranteed final debit' ) - expect(mockConfirm.mock.calls[0]?.[0].message).toContain('source cap: 4 USDC') expect(mockPlan).toHaveBeenCalledOnce() }) diff --git a/src/test/unit/squid-funding.test.ts b/src/test/unit/squid-funding.test.ts index ec4ed6c9..27869e8f 100644 --- a/src/test/unit/squid-funding.test.ts +++ b/src/test/unit/squid-funding.test.ts @@ -197,11 +197,20 @@ describe('Squid payment shortfalls', () => { it('wires all eight source-chain policies and OP Stack fee accounting', async () => { for (const [fromChain, chainId] of Object.entries(CHAIN_IDS)) { - await acquirePaymentShortfalls(input({ options: { ...input().options, fromChain } })) + const request = input({ options: { ...input().options, fromChain } }) + await acquirePaymentShortfalls(request) expect(mockResolve).toHaveBeenLastCalledWith({}, chainId, 'USDC') 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 }), + }) + ) + } } }) From 2f3b0f5abec732d6bc30ac713043184a3599bc96 Mon Sep 17 00:00:00 2001 From: Mikers Date: Wed, 29 Jul 2026 17:31:37 -1000 Subject: [PATCH 04/26] docs(payments): clarify Squid funding limits --- src/payments/fund.ts | 2 +- src/test/unit/run-fund-squid.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/payments/fund.ts b/src/payments/fund.ts index 2436eee8..c0e26fde 100644 --- a/src/payments/fund.ts +++ b/src/payments/fund.ts @@ -318,7 +318,7 @@ export async function runFund(options: FundOptions): Promise { ) .join(' and ') const proceed = await confirm({ - message: `Spend ${formatUnits(spend, summary.source.decimals)} ${summary.source.symbol} from ${summary.source.chain.networkName} via Squid to receive ${targets} on Filecoin (source-token cap: ${formatUnits(summary.maxSourceAmount, summary.source.decimals)} ${summary.source.symbol}; additional network-fee commitment cap: ${formatUnits(summary.maxNativeFee, summary.nativeCurrency.decimals)} ${summary.nativeCurrency.symbol}, not a guaranteed final debit)?`, + message: `Spend ${formatUnits(spend, summary.source.decimals)} ${summary.source.symbol} from ${summary.source.chain.networkName} via Squid to receive ${targets} on Filecoin (source-token limit: ${formatUnits(summary.maxSourceAmount, summary.source.decimals)} ${summary.source.symbol}; buffered network-fee 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') diff --git a/src/test/unit/run-fund-squid.test.ts b/src/test/unit/run-fund-squid.test.ts index 42cd8ce4..8b758919 100644 --- a/src/test/unit/run-fund-squid.test.ts +++ b/src/test/unit/run-fund-squid.test.ts @@ -135,9 +135,9 @@ describe('interactive Squid funding command', () => { expect(mockConfirm).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining('Spend 3 USDC from Ethereum') }) ) - expect(mockConfirm.mock.calls[0]?.[0].message).toContain('source-token cap: 4 USDC') + expect(mockConfirm.mock.calls[0]?.[0].message).toContain('source-token limit: 4 USDC') expect(mockConfirm.mock.calls[0]?.[0].message).toContain( - 'additional network-fee commitment cap: 0.03 ETH, not a guaranteed final debit' + 'buffered network-fee limit: 0.03 ETH; final network fees may vary' ) expect(mockPlan).toHaveBeenCalledOnce() }) From a9343da17605cd28c13cf9bae8d0cb03f399c87a Mon Sep 17 00:00:00 2001 From: Mikers Date: Wed, 29 Jul 2026 17:37:34 -1000 Subject: [PATCH 05/26] test(payments): handle Windows marker modes --- src/test/unit/squid-funding.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/unit/squid-funding.test.ts b/src/test/unit/squid-funding.test.ts index 27869e8f..b2957a8e 100644 --- a/src/test/unit/squid-funding.test.ts +++ b/src/test/unit/squid-funding.test.ts @@ -147,7 +147,7 @@ describe('Squid payment shortfalls', () => { await expect(stat(marker)).rejects.toMatchObject({ code: 'ENOENT' }) }) - it('leaves a mode-0600 marker when execution fails and sanitizes secrets', async () => { + 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 @@ -167,7 +167,7 @@ describe('Squid payment shortfalls', () => { for (const secret of [PRIVATE_KEY, privateKey, rpcUrl, integratorId]) { expect(serialized).not.toContain(secret) } - expect((await stat(marker)).mode & 0o777).toBe(0o600) + if (process.platform !== 'win32') expect((await stat(marker)).mode & 0o777).toBe(0o600) expect(JSON.parse(await readFile(marker, 'utf8'))).toMatchObject({ owner: OWNER, sourceChain: 42161, From 960535f9e47773271ea70c34a60366dc37efca2e Mon Sep 17 00:00:00 2001 From: Mikers Date: Thu, 30 Jul 2026 14:17:41 -1000 Subject: [PATCH 06/26] refactor(payments): simplify Squid funding integration --- package.json | 2 +- pnpm-lock.yaml | 10 +-- src/payments/fund.ts | 21 ++---- src/payments/squid-funding.ts | 101 ++++++++------------------ src/test/unit/run-fund-squid.test.ts | 32 ++------- src/test/unit/run-fund.test.ts | 3 +- src/test/unit/squid-funding.test.ts | 102 +++++++++++++++++---------- 7 files changed, 112 insertions(+), 159 deletions(-) diff --git a/package.json b/package.json index de6e85c8..ab47e1a6 100644 --- a/package.json +++ b/package.json @@ -152,7 +152,7 @@ "picocolors": "^1.1.1", "pino": "^10.3.1", "semver": "^7.7.4", - "squid-evm-funding": "github:snissn/squid-evm-funding#ad4c7f007637d9ae2ba2f2bdb48cf9f7beea01b6", + "squid-evm-funding": "github:snissn/squid-evm-funding#831c922c7e3ec138a12aea25b0fa93539562993c", "varint": "^6.0.0", "viem": "^2.48.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 865dc780..43bd631d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,8 +93,8 @@ importers: specifier: ^7.7.4 version: 7.8.5 squid-evm-funding: - specifier: github:snissn/squid-evm-funding#ad4c7f007637d9ae2ba2f2bdb48cf9f7beea01b6 - version: https://codeload.github.com/snissn/squid-evm-funding/tar.gz/ad4c7f007637d9ae2ba2f2bdb48cf9f7beea01b6(typescript@6.0.3)(zod@4.3.6) + specifier: github:snissn/squid-evm-funding#831c922c7e3ec138a12aea25b0fa93539562993c + version: https://codeload.github.com/snissn/squid-evm-funding/tar.gz/831c922c7e3ec138a12aea25b0fa93539562993c(typescript@6.0.3)(zod@4.3.6) varint: specifier: ^6.0.0 version: 6.0.0 @@ -2117,8 +2117,8 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} - squid-evm-funding@https://codeload.github.com/snissn/squid-evm-funding/tar.gz/ad4c7f007637d9ae2ba2f2bdb48cf9f7beea01b6: - resolution: {tarball: https://codeload.github.com/snissn/squid-evm-funding/tar.gz/ad4c7f007637d9ae2ba2f2bdb48cf9f7beea01b6} + squid-evm-funding@https://codeload.github.com/snissn/squid-evm-funding/tar.gz/831c922c7e3ec138a12aea25b0fa93539562993c: + resolution: {tarball: https://codeload.github.com/snissn/squid-evm-funding/tar.gz/831c922c7e3ec138a12aea25b0fa93539562993c} version: 0.1.0 engines: {node: '>=24'} @@ -4663,7 +4663,7 @@ snapshots: split2@4.2.0: {} - squid-evm-funding@https://codeload.github.com/snissn/squid-evm-funding/tar.gz/ad4c7f007637d9ae2ba2f2bdb48cf9f7beea01b6(typescript@6.0.3)(zod@4.3.6): + squid-evm-funding@https://codeload.github.com/snissn/squid-evm-funding/tar.gz/831c922c7e3ec138a12aea25b0fa93539562993c(typescript@6.0.3)(zod@4.3.6): dependencies: viem: 2.48.4(typescript@6.0.3)(zod@4.3.6) transitivePeerDependencies: diff --git a/src/payments/fund.ts b/src/payments/fund.ts index c0e26fde..0f3eff1e 100644 --- a/src/payments/fund.ts +++ b/src/payments/fund.ts @@ -24,14 +24,14 @@ import { toStorageRunwaySummary, withdrawUSDFC, } from '../core/payments/index.js' -import { getClientAddress, initializeSynapse, mainnet } 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, isFundingSourceRequested, validateFundingSourceOptions } from './squid-funding.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 @@ -249,8 +249,7 @@ export async function runFund(options: FundOptions): Promise { spinner.start('Connecting...') try { - const sourceRequested = isFundingSourceRequested(options) - validateFundingSourceOptions(options) + const sourceRequested = validateFundingSourceOptions(options) // Parse and validate authentication const authConfig = parseCLIAuth(options) @@ -290,18 +289,6 @@ export async function runFund(options: FundOptions): Promise { const usdfcShortfall = preview.delta > 0n ? (preview.walletShortfall ?? 0n) : 0n if (filShortfall > 0n || usdfcShortfall > 0n) { if (!isInteractive()) throw new Error('Squid source acquisition requires an interactive terminal') - if (synapse.chain.id !== mainnet.id) { - throw new Error('Squid source acquisition is available only for Filecoin Mainnet') - } - if ( - options.privateKey == null || - options.privateKey.trim() === '' || - options.walletAddress != null || - options.sessionKey != null || - options.viewAddress != null - ) { - throw new Error('Squid source acquisition requires owner private-key auth') - } await acquirePaymentShortfalls({ synapse, owner: getClientAddress(synapse), @@ -318,7 +305,7 @@ export async function runFund(options: FundOptions): Promise { ) .join(' and ') const proceed = await confirm({ - message: `Spend ${formatUnits(spend, summary.source.decimals)} ${summary.source.symbol} from ${summary.source.chain.networkName} via Squid to receive ${targets} on Filecoin (source-token limit: ${formatUnits(summary.maxSourceAmount, summary.source.decimals)} ${summary.source.symbol}; buffered network-fee limit: ${formatUnits(summary.maxNativeFee, summary.nativeCurrency.decimals)} ${summary.nativeCurrency.symbol}; final network fees may vary)?`, + 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}; buffered network-fee 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') diff --git a/src/payments/squid-funding.ts b/src/payments/squid-funding.ts index d790051a..d3f12300 100644 --- a/src/payments/squid-funding.ts +++ b/src/payments/squid-funding.ts @@ -1,29 +1,17 @@ -import { access, mkdir, unlink, writeFile } from 'node:fs/promises' +import { mkdir, unlink, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import type { Synapse } from '@filoz/synapse-sdk' import { type DestinationRequirement, executeSquidFunding, - fetchSquidCatalog, NATIVE_TOKEN_ADDRESS, planSquidFunding, - quoteSquidRoute, - resolveSourceToken, type SourceToken, type SquidPublicClient, type SquidQuote, type SquidWalletClient, } from 'squid-evm-funding' -import { - type Address, - type Chain, - createPublicClient, - createWalletClient, - getAddress, - type Hex, - http, - parseUnits, -} from 'viem' +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' @@ -31,6 +19,7 @@ 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') @@ -59,6 +48,7 @@ const SOURCE_POLICIES: readonly SourcePolicy[] = [ interface PaymentAcquisitionSummary { source: SourceToken + sourceChainName: string quotes: readonly SquidQuote[] maxSourceAmount: bigint maxNativeFee: bigint @@ -72,18 +62,15 @@ export interface AcquirePaymentShortfallsInput { usdfcShortfall: bigint /** Wallet USDFC reserved for the later Filecoin Pay deposit. */ requiredWalletUsdfc: bigint - options: FundingSourceOptions & { privateKey?: string | undefined } + options: FundingSourceOptions & Pick confirm: (summary: PaymentAcquisitionSummary) => Promise } -export function isFundingSourceRequested(options: FundingSourceOptions): boolean { - return [options.fromChain, options.fromToken, options.maxSourceAmount, options.sourceRpcUrl].some( +export function validateFundingSourceOptions(options: FundingSourceOptions): boolean { + const requested = [options.fromChain, options.fromToken, options.maxSourceAmount, options.sourceRpcUrl].some( (value) => value != null && value.trim() !== '' ) -} - -export function validateFundingSourceOptions(options: FundingSourceOptions): void { - if (!isFundingSourceRequested(options)) return + if (!requested) return false if ( [options.fromChain, options.fromToken, options.maxSourceAmount, options.sourceRpcUrl].some( (value) => !value?.trim() @@ -93,6 +80,7 @@ export function validateFundingSourceOptions(options: FundingSourceOptions): voi 'Source acquisition requires --from-chain, --from-token, --max-source-amount, and --source-rpc-url together' ) } + return true } function sourcePolicy(name: string | undefined): SourcePolicy { @@ -162,16 +150,6 @@ function pendingError(path: string): Error { ) } -async function assertNoPendingMarker(path: string): Promise { - try { - await access(path) - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return - throw error - } - throw pendingError(path) -} - function makeSourceClients(policy: SourcePolicy, rpcUrl: string, privateKey: string | undefined, owner: Address) { const account = signingAccount(privateKey, owner) const transport = http(rpcUrl, { timeout: REQUEST_TIMEOUT_MS }) @@ -185,17 +163,18 @@ function makeSourceClients(policy: SourcePolicy, rpcUrl: string, privateKey: str } /** Acquire only the positive FIL and USDFC shortfalls supplied by the existing payment planner. */ -export async function acquirePaymentShortfalls(input: AcquirePaymentShortfallsInput): Promise { +export async function acquirePaymentShortfalls(input: AcquirePaymentShortfallsInput): Promise { const destinationRequirements = requirements(input) - if (destinationRequirements.length === 0) return false + if (destinationRequirements.length === 0) 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() - await assertNoPendingMarker(path) - const policy = sourcePolicy(input.options.fromChain) const sourceRpcUrl = input.options.sourceRpcUrl as string const integratorId = process.env.SQUID_INTEGRATOR_ID ?? '' @@ -204,32 +183,30 @@ export async function acquirePaymentShortfalls(input: AcquirePaymentShortfallsIn try { const clients = makeSourceClients(policy, sourceRpcUrl, input.options.privateKey, input.owner) - const catalog = await fetchSquidCatalog(squid) - const source = resolveSourceToken(catalog, policy.chain.id, input.options.fromToken as string) - const maxSourceAmount = parseUnits(input.options.maxSourceAmount as string, source.decimals) - if (maxSourceAmount <= 0n) throw new Error('--max-source-amount must be greater than zero') - const quotes = await planSquidFunding( + const plan = await planSquidFunding( { owner: input.owner, - source, + sourceChainId: policy.chain.id, + sourceToken: input.options.fromToken as string, requirements: destinationRequirements, - maxSourceAmount, + maxSourceAmount: input.options.maxSourceAmount as string, slippage: SLIPPAGE_PERCENT, }, squid ) const sourceBalanceFloor = - policy.chain.id === filecoinMainnet.id && source.token.toLowerCase() === FILECOIN_USDFC.toLowerCase() + policy.chain.id === filecoinMainnet.id && plan.source.token.toLowerCase() === FILECOIN_USDFC.toLowerCase() ? input.requiredWalletUsdfc - : policy.chain.id === filecoinMainnet.id && source.native + : policy.chain.id === filecoinMainnet.id && plan.source.token === NATIVE_TOKEN_ADDRESS ? MIN_FIL_FOR_GAS : 0n const nativeBalanceFloor = policy.chain.id === filecoinMainnet.id ? MIN_FIL_FOR_GAS : 0n await input.confirm({ - source, - quotes, - maxSourceAmount, + source: plan.source, + sourceChainName: policy.chain.name, + quotes: plan.quotes, + maxSourceAmount: plan.maxSourceAmount, maxNativeFee: policy.maxNativeFee, nativeCurrency: policy.chain.nativeCurrency, }) @@ -239,9 +216,9 @@ export async function acquirePaymentShortfalls(input: AcquirePaymentShortfallsIn path, JSON.stringify({ owner: input.owner, - sourceChain: source.chain.chainId, - sourceToken: source.token, - maxSourceAmount: maxSourceAmount.toString(), + sourceChain: plan.source.chainId, + sourceToken: plan.source.token, + maxSourceAmount: plan.maxSourceAmount.toString(), createdAt: new Date().toISOString(), targets: destinationRequirements.map((requirement) => ({ token: requirement.token, @@ -257,10 +234,7 @@ export async function acquirePaymentShortfalls(input: AcquirePaymentShortfallsIn await executeSquidFunding( { - account: clients.account.address, - source, - quotes, - maxSourceAmount, + plan, maxNativeFee: policy.maxNativeFee, sourceBalanceFloor, nativeBalanceFloor, @@ -274,26 +248,11 @@ export async function acquirePaymentShortfalls(input: AcquirePaymentShortfallsIn { publicClient: clients.publicClient, walletClient: clients.walletClient, - destinationClient: (chainId) => { - if (chainId !== filecoinMainnet.id) throw new Error(`Unsupported destination chain ${chainId}`) - return input.synapse.client as unknown as SquidPublicClient - }, - refreshQuote: (quote) => - quoteSquidRoute( - { - owner: input.owner, - source: quote.source, - requirement: quote.requirement, - sourceAmount: quote.sourceAmount, - slippage: SLIPPAGE_PERCENT, - }, - squid - ), - squidStatusOptions: squid, + destinationClient: input.synapse.client as unknown as SquidPublicClient, + squid, } ) await unlink(path) - return true } catch (error) { if (error instanceof CliIncomplete) throw error const privateKey = input.options.privateKey diff --git a/src/test/unit/run-fund-squid.test.ts b/src/test/unit/run-fund-squid.test.ts index 8b758919..65a4cf95 100644 --- a/src/test/unit/run-fund-squid.test.ts +++ b/src/test/unit/run-fund-squid.test.ts @@ -50,17 +50,15 @@ vi.mock('../../core/utils/format.js', () => ({ formatUSDFC: vi.fn((value: bigint vi.mock('../../core/utils/index.js', () => ({ formatRunwaySummary: vi.fn(() => ({ coverage: 'No spend' })) })) vi.mock('../../payments/squid-funding.js', () => ({ acquirePaymentShortfalls: mockAcquire, - isFundingSourceRequested: (options: Record) => - [options.fromChain, options.fromToken, options.maxSourceAmount, options.sourceRpcUrl].some( - (value) => value != null && value.trim() !== '' - ), validateFundingSourceOptions: (options: Record) => { const values = [options.fromChain, options.fromToken, options.maxSourceAmount, options.sourceRpcUrl] - if (values.some((value) => value != null && value.trim() !== '') && values.some((value) => !value?.trim())) { + const requested = values.some((value) => value != null && value.trim() !== '') + if (requested && values.some((value) => !value?.trim())) { throw new Error( 'Source acquisition requires --from-chain, --from-token, --max-source-amount, and --source-rpc-url together' ) } + return requested }, })) @@ -102,7 +100,8 @@ describe('interactive Squid funding command', () => { mockPlan.mockResolvedValue(refreshedPlan()) mockAcquire.mockImplementation(async (options) => { await options.confirm({ - source: { symbol: 'USDC', decimals: 6, chain: { networkName: 'Ethereum' } }, + 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 }, @@ -117,7 +116,6 @@ describe('interactive Squid funding command', () => { }, ], }) - return true }) }) @@ -161,26 +159,6 @@ describe('interactive Squid funding command', () => { expect(mockAcquire).not.toHaveBeenCalled() }) - it('fails explicit Calibration selection before contacting Squid', async () => { - mockInitialize.mockResolvedValueOnce({ - chain: { id: 314159 }, - payments: { accountSummary: vi.fn(async () => ({ funds: 0n })) }, - }) - await expect(runFund({ ...sourceOptions, network: 'calibration' })).rejects.toThrow(/only for Filecoin Mainnet/) - expect(mockAcquire).not.toHaveBeenCalled() - }) - - it('fails a probed non-Mainnet RPC before payment or Squid calls', async () => { - mockInitialize.mockResolvedValueOnce({ - chain: { id: 314159 }, - payments: { accountSummary: vi.fn(async () => ({ funds: 0n })) }, - }) - - await expect(runFund(sourceOptions)).rejects.toThrow(/only for Filecoin Mainnet/) - expect(mockGetPaymentStatus).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() diff --git a/src/test/unit/run-fund.test.ts b/src/test/unit/run-fund.test.ts index fffa2e58..77778062 100644 --- a/src/test/unit/run-fund.test.ts +++ b/src/test/unit/run-fund.test.ts @@ -19,8 +19,7 @@ vi.mock('../../core/synapse/index.js', () => ({ })) vi.mock('../../payments/squid-funding.js', () => ({ acquirePaymentShortfalls: vi.fn(), - isFundingSourceRequested: vi.fn(() => false), - validateFundingSourceOptions: vi.fn(), + validateFundingSourceOptions: vi.fn(() => false), })) vi.mock('../../utils/cli-auth.js', () => ({ parseCLIAuth: vi.fn(() => ({})), diff --git a/src/test/unit/squid-funding.test.ts b/src/test/unit/squid-funding.test.ts index b2957a8e..37aa5902 100644 --- a/src/test/unit/squid-funding.test.ts +++ b/src/test/unit/squid-funding.test.ts @@ -6,21 +6,15 @@ import { privateKeyToAccount } from 'viem/accounts' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { acquirePaymentShortfalls, validateFundingSourceOptions } from '../../payments/squid-funding.js' -const { mockExecute, mockFetchCatalog, mockPlan, mockQuote, mockResolve } = vi.hoisted(() => ({ +const { mockExecute, mockPlan } = vi.hoisted(() => ({ mockExecute: vi.fn(), - mockFetchCatalog: vi.fn(), mockPlan: vi.fn(), - mockQuote: vi.fn(), - mockResolve: vi.fn(), })) vi.mock('squid-evm-funding', () => ({ NATIVE_TOKEN_ADDRESS: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', executeSquidFunding: mockExecute, - fetchSquidCatalog: mockFetchCatalog, planSquidFunding: mockPlan, - quoteSquidRoute: mockQuote, - resolveSourceToken: mockResolve, })) const PRIVATE_KEY = `0x${'01'.padStart(64, '0')}` as Hex @@ -85,28 +79,27 @@ describe('Squid payment shortfalls', () => { delete process.env.NETWORK delete process.env.RPC_URL - mockFetchCatalog.mockResolvedValue({}) - mockResolve.mockImplementation((_catalog, chainId) => ({ - chain: { chainId, networkName: 'source' }, - token: '0x1111111111111111111111111111111111111111', - symbol: 'USDC', - decimals: 6, - native: false, - })) - mockPlan.mockImplementation(async ({ source, requirements }) => - requirements.map((requirement: Record, index: number) => ({ + mockPlan.mockImplementation(async ({ owner, sourceChainId, requirements, maxSourceAmount }) => ({ + owner, + source: { + chainId: sourceChainId, + token: '0x1111111111111111111111111111111111111111', + symbol: 'USDC', + decimals: 6, + }, + quotes: requirements.map((requirement: Record, index: number) => ({ id: `quote-${index}`, requirement, - source, sourceAmount: 1_000_000n, destinationAmount: requirement.amount, target: ROUTER, data: '0x12', value: 0n, - gasLimit: 1n, expiresAt: 2_000_000_000, - })) - ) + })), + maxSourceAmount: BigInt(maxSourceAmount) * 1_000_000n, + slippage: 1, + })) mockExecute.mockResolvedValue({ sourceAmount: 2_000_000n, nativeFee: 1n, routes: [] }) }) @@ -127,19 +120,20 @@ describe('Squid payment shortfalls', () => { }) it('requires all four source options together', () => { + expect(validateFundingSourceOptions({})).toBe(false) expect(() => validateFundingSourceOptions({ fromChain: 'arbitrum' })).toThrow(/requires --from-chain/) - expect(() => + expect( validateFundingSourceOptions({ fromChain: 'arbitrum', fromToken: 'USDC', maxSourceAmount: '1', sourceRpcUrl: 'https://rpc.example', }) - ).not.toThrow() + ).toBe(true) }) it('passes exact FIL and USDFC shortfalls and removes the marker after verified execution', async () => { - await expect(acquirePaymentShortfalls(input())).resolves.toBe(true) + 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]) @@ -175,16 +169,16 @@ describe('Squid payment shortfalls', () => { }) }) - it('refuses a pending marker before contacting Squid', async () => { + it('uses atomic marker creation to block an ambiguous rerun', async () => { await writeFile(marker, '{}', { mode: 0o600 }) await expect(acquirePaymentShortfalls(input())).rejects.toThrow(marker) - expect(mockFetchCatalog).not.toHaveBeenCalled() + expect(mockExecute).not.toHaveBeenCalled() }) it('does nothing when there is no shortfall', async () => { - await expect(acquirePaymentShortfalls(input({ filShortfall: 0n, usdfcShortfall: 0n }))).resolves.toBe(false) - expect(mockFetchCatalog).not.toHaveBeenCalled() + await expect(acquirePaymentShortfalls(input({ filShortfall: 0n, usdfcShortfall: 0n }))).resolves.toBeUndefined() + expect(mockPlan).not.toHaveBeenCalled() expect(mockExecute).not.toHaveBeenCalled() }) @@ -192,14 +186,27 @@ describe('Squid payment shortfalls', () => { await expect( acquirePaymentShortfalls(input({ owner: '0x2222222222222222222222222222222222222222' })) ).rejects.toThrow(/must control the Filecoin payment owner/) - expect(mockFetchCatalog).not.toHaveBeenCalled() + 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(mockResolve).toHaveBeenLastCalledWith({}, chainId, 'USDC') + 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) @@ -215,17 +222,40 @@ describe('Squid payment shortfalls', () => { }) it('preserves the Filecoin FIL and USDFC reserves when Filecoin is the source', async () => { - mockResolve.mockReturnValueOnce({ - chain: { chainId: 314, networkName: 'filecoin' }, - token: '0x80B98d3aa09ffff255c3ba4A241111Ff1262F045', - symbol: 'USDFC', - decimals: 18, - native: false, + 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(mockExecute.mock.calls[0]?.[0]).toMatchObject({ sourceBalanceFloor: 8n, nativeBalanceFloor: 100_000_000_000_000_000n, }) + + mockPlan.mockResolvedValueOnce({ + owner: OWNER, + source: { + chainId: 314, + token: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + symbol: 'FIL', + decimals: 18, + }, + quotes: [], + maxSourceAmount: 10n, + slippage: 1, + }) + await acquirePaymentShortfalls(input({ options: { ...input().options, fromChain: 'filecoin' } })) + expect(mockExecute.mock.calls[1]?.[0]).toMatchObject({ + sourceBalanceFloor: 100_000_000_000_000_000n, + nativeBalanceFloor: 100_000_000_000_000_000n, + }) }) }) From d51ef2c9238fd841a8feda3f3bfbf2fb415ec5ef Mon Sep 17 00:00:00 2001 From: Mikers Date: Thu, 30 Jul 2026 16:12:34 -1000 Subject: [PATCH 07/26] fix(payments): pin reviewed Squid signing fix --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index ab47e1a6..02e598a4 100644 --- a/package.json +++ b/package.json @@ -152,7 +152,7 @@ "picocolors": "^1.1.1", "pino": "^10.3.1", "semver": "^7.7.4", - "squid-evm-funding": "github:snissn/squid-evm-funding#831c922c7e3ec138a12aea25b0fa93539562993c", + "squid-evm-funding": "github:snissn/squid-evm-funding#ba3c5bba2071f75bcf6f8e8fc00b7c945f3a7756", "varint": "^6.0.0", "viem": "^2.48.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 43bd631d..34638123 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,8 +93,8 @@ importers: specifier: ^7.7.4 version: 7.8.5 squid-evm-funding: - specifier: github:snissn/squid-evm-funding#831c922c7e3ec138a12aea25b0fa93539562993c - version: https://codeload.github.com/snissn/squid-evm-funding/tar.gz/831c922c7e3ec138a12aea25b0fa93539562993c(typescript@6.0.3)(zod@4.3.6) + specifier: github:snissn/squid-evm-funding#ba3c5bba2071f75bcf6f8e8fc00b7c945f3a7756 + version: https://codeload.github.com/snissn/squid-evm-funding/tar.gz/ba3c5bba2071f75bcf6f8e8fc00b7c945f3a7756(typescript@6.0.3)(zod@4.3.6) varint: specifier: ^6.0.0 version: 6.0.0 @@ -2117,8 +2117,8 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} - squid-evm-funding@https://codeload.github.com/snissn/squid-evm-funding/tar.gz/831c922c7e3ec138a12aea25b0fa93539562993c: - resolution: {tarball: https://codeload.github.com/snissn/squid-evm-funding/tar.gz/831c922c7e3ec138a12aea25b0fa93539562993c} + squid-evm-funding@https://codeload.github.com/snissn/squid-evm-funding/tar.gz/ba3c5bba2071f75bcf6f8e8fc00b7c945f3a7756: + resolution: {tarball: https://codeload.github.com/snissn/squid-evm-funding/tar.gz/ba3c5bba2071f75bcf6f8e8fc00b7c945f3a7756} version: 0.1.0 engines: {node: '>=24'} @@ -4663,7 +4663,7 @@ snapshots: split2@4.2.0: {} - squid-evm-funding@https://codeload.github.com/snissn/squid-evm-funding/tar.gz/831c922c7e3ec138a12aea25b0fa93539562993c(typescript@6.0.3)(zod@4.3.6): + squid-evm-funding@https://codeload.github.com/snissn/squid-evm-funding/tar.gz/ba3c5bba2071f75bcf6f8e8fc00b7c945f3a7756(typescript@6.0.3)(zod@4.3.6): dependencies: viem: 2.48.4(typescript@6.0.3)(zod@4.3.6) transitivePeerDependencies: From 5b0563d69a6a33681a1fee352d90d1c2306b8d72 Mon Sep 17 00:00:00 2001 From: Mikers Date: Thu, 30 Jul 2026 16:25:55 -1000 Subject: [PATCH 08/26] chore(payments): use squid-evm-funding v0.1.0 --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 02e598a4..e1f64de3 100644 --- a/package.json +++ b/package.json @@ -152,7 +152,7 @@ "picocolors": "^1.1.1", "pino": "^10.3.1", "semver": "^7.7.4", - "squid-evm-funding": "github:snissn/squid-evm-funding#ba3c5bba2071f75bcf6f8e8fc00b7c945f3a7756", + "squid-evm-funding": "github:snissn/squid-evm-funding#v0.1.0", "varint": "^6.0.0", "viem": "^2.48.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34638123..d223d83e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,8 +93,8 @@ importers: specifier: ^7.7.4 version: 7.8.5 squid-evm-funding: - specifier: github:snissn/squid-evm-funding#ba3c5bba2071f75bcf6f8e8fc00b7c945f3a7756 - version: https://codeload.github.com/snissn/squid-evm-funding/tar.gz/ba3c5bba2071f75bcf6f8e8fc00b7c945f3a7756(typescript@6.0.3)(zod@4.3.6) + specifier: github:snissn/squid-evm-funding#v0.1.0 + version: https://codeload.github.com/snissn/squid-evm-funding/tar.gz/b89293a964cc233909cac60899c9c1a0bc7a6df9(typescript@6.0.3)(zod@4.3.6) varint: specifier: ^6.0.0 version: 6.0.0 @@ -2117,8 +2117,8 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} - squid-evm-funding@https://codeload.github.com/snissn/squid-evm-funding/tar.gz/ba3c5bba2071f75bcf6f8e8fc00b7c945f3a7756: - resolution: {tarball: https://codeload.github.com/snissn/squid-evm-funding/tar.gz/ba3c5bba2071f75bcf6f8e8fc00b7c945f3a7756} + squid-evm-funding@https://codeload.github.com/snissn/squid-evm-funding/tar.gz/b89293a964cc233909cac60899c9c1a0bc7a6df9: + resolution: {tarball: https://codeload.github.com/snissn/squid-evm-funding/tar.gz/b89293a964cc233909cac60899c9c1a0bc7a6df9} version: 0.1.0 engines: {node: '>=24'} @@ -4663,7 +4663,7 @@ snapshots: split2@4.2.0: {} - squid-evm-funding@https://codeload.github.com/snissn/squid-evm-funding/tar.gz/ba3c5bba2071f75bcf6f8e8fc00b7c945f3a7756(typescript@6.0.3)(zod@4.3.6): + squid-evm-funding@https://codeload.github.com/snissn/squid-evm-funding/tar.gz/b89293a964cc233909cac60899c9c1a0bc7a6df9(typescript@6.0.3)(zod@4.3.6): dependencies: viem: 2.48.4(typescript@6.0.3)(zod@4.3.6) transitivePeerDependencies: From 7d82bf51cd9597fdbe9ae9f89b4180adea20d6e8 Mon Sep 17 00:00:00 2001 From: Mikers Date: Thu, 6 Aug 2026 09:06:48 -1000 Subject: [PATCH 09/26] chore(deps): update squid-evm-funding repository --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index e1f64de3..5629cf7b 100644 --- a/package.json +++ b/package.json @@ -152,7 +152,7 @@ "picocolors": "^1.1.1", "pino": "^10.3.1", "semver": "^7.7.4", - "squid-evm-funding": "github:snissn/squid-evm-funding#v0.1.0", + "squid-evm-funding": "github:FilOzone/squid-evm-funding#v0.1.0", "varint": "^6.0.0", "viem": "^2.48.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d223d83e..89ac6944 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,8 +93,8 @@ importers: specifier: ^7.7.4 version: 7.8.5 squid-evm-funding: - specifier: github:snissn/squid-evm-funding#v0.1.0 - version: https://codeload.github.com/snissn/squid-evm-funding/tar.gz/b89293a964cc233909cac60899c9c1a0bc7a6df9(typescript@6.0.3)(zod@4.3.6) + specifier: github:FilOzone/squid-evm-funding#v0.1.0 + version: https://codeload.github.com/FilOzone/squid-evm-funding/tar.gz/b89293a964cc233909cac60899c9c1a0bc7a6df9(typescript@6.0.3)(zod@4.3.6) varint: specifier: ^6.0.0 version: 6.0.0 @@ -2117,8 +2117,8 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} - squid-evm-funding@https://codeload.github.com/snissn/squid-evm-funding/tar.gz/b89293a964cc233909cac60899c9c1a0bc7a6df9: - resolution: {tarball: https://codeload.github.com/snissn/squid-evm-funding/tar.gz/b89293a964cc233909cac60899c9c1a0bc7a6df9} + squid-evm-funding@https://codeload.github.com/FilOzone/squid-evm-funding/tar.gz/b89293a964cc233909cac60899c9c1a0bc7a6df9: + resolution: {tarball: https://codeload.github.com/FilOzone/squid-evm-funding/tar.gz/b89293a964cc233909cac60899c9c1a0bc7a6df9} version: 0.1.0 engines: {node: '>=24'} @@ -4663,7 +4663,7 @@ snapshots: split2@4.2.0: {} - squid-evm-funding@https://codeload.github.com/snissn/squid-evm-funding/tar.gz/b89293a964cc233909cac60899c9c1a0bc7a6df9(typescript@6.0.3)(zod@4.3.6): + squid-evm-funding@https://codeload.github.com/FilOzone/squid-evm-funding/tar.gz/b89293a964cc233909cac60899c9c1a0bc7a6df9(typescript@6.0.3)(zod@4.3.6): dependencies: viem: 2.48.4(typescript@6.0.3)(zod@4.3.6) transitivePeerDependencies: From 14631999543327f11ca84693cdcc9e3250794adf Mon Sep 17 00:00:00 2001 From: Mikers Date: Tue, 18 Aug 2026 10:10:31 -1000 Subject: [PATCH 10/26] refactor(payments): remove nested source floor ternary --- src/payments/squid-funding.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/payments/squid-funding.ts b/src/payments/squid-funding.ts index d3f12300..ea6a896d 100644 --- a/src/payments/squid-funding.ts +++ b/src/payments/squid-funding.ts @@ -194,12 +194,14 @@ export async function acquirePaymentShortfalls(input: AcquirePaymentShortfallsIn }, squid ) - const sourceBalanceFloor = - policy.chain.id === filecoinMainnet.id && plan.source.token.toLowerCase() === FILECOIN_USDFC.toLowerCase() - ? input.requiredWalletUsdfc - : policy.chain.id === filecoinMainnet.id && plan.source.token === NATIVE_TOKEN_ADDRESS - ? MIN_FIL_FOR_GAS - : 0n + 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 ? MIN_FIL_FOR_GAS : 0n await input.confirm({ From 207a29107f1914d07e3a86713bd991217ded89bf Mon Sep 17 00:00:00 2001 From: Mikers Date: Tue, 18 Aug 2026 10:11:06 -1000 Subject: [PATCH 11/26] fix(payments): allow Filecoin USDFC FIL bootstrap --- src/payments/squid-funding.ts | 11 ++++++----- src/test/unit/squid-funding.test.ts | 11 +++++++---- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/payments/squid-funding.ts b/src/payments/squid-funding.ts index ea6a896d..2cfeef7d 100644 --- a/src/payments/squid-funding.ts +++ b/src/payments/squid-funding.ts @@ -99,7 +99,7 @@ function signingAccount(value: string | undefined, owner: Address) { return account } -function requirements(input: AcquirePaymentShortfallsInput): DestinationRequirement[] { +function requirements(input: AcquirePaymentShortfallsInput, filFeeBuffer: bigint): DestinationRequirement[] { return [ ...(input.filShortfall > 0n ? [ @@ -107,7 +107,7 @@ function requirements(input: AcquirePaymentShortfallsInput): DestinationRequirem id: 'filecoin-fil', chainId: filecoinMainnet.id, token: NATIVE_TOKEN_ADDRESS, - amount: input.filShortfall, + amount: input.filShortfall + filFeeBuffer, recipient: input.owner, }, ] @@ -164,8 +164,7 @@ function makeSourceClients(policy: SourcePolicy, rpcUrl: string, privateKey: str /** Acquire only the positive FIL and USDFC shortfalls supplied by the existing payment planner. */ export async function acquirePaymentShortfalls(input: AcquirePaymentShortfallsInput): Promise { - const destinationRequirements = requirements(input) - if (destinationRequirements.length === 0) return + 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') @@ -176,6 +175,8 @@ export async function acquirePaymentShortfalls(input: AcquirePaymentShortfallsIn 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 ?? '' if (integratorId.trim() === '') throw new Error('SQUID_INTEGRATOR_ID is required') @@ -202,7 +203,7 @@ export async function acquirePaymentShortfalls(input: AcquirePaymentShortfallsIn sourceBalanceFloor = MIN_FIL_FOR_GAS } } - const nativeBalanceFloor = policy.chain.id === filecoinMainnet.id ? MIN_FIL_FOR_GAS : 0n + const nativeBalanceFloor = policy.chain.id === filecoinMainnet.id && input.filShortfall <= 0n ? MIN_FIL_FOR_GAS : 0n await input.confirm({ source: plan.source, diff --git a/src/test/unit/squid-funding.test.ts b/src/test/unit/squid-funding.test.ts index 37aa5902..cb5bb68a 100644 --- a/src/test/unit/squid-funding.test.ts +++ b/src/test/unit/squid-funding.test.ts @@ -221,7 +221,7 @@ describe('Squid payment shortfalls', () => { } }) - it('preserves the Filecoin FIL and USDFC reserves when Filecoin is the source', async () => { + it('allows Filecoin USDFC to acquire a FIL shortfall without spending reserved USDFC', async () => { mockPlan.mockResolvedValueOnce({ owner: OWNER, source: { @@ -235,11 +235,14 @@ describe('Squid payment shortfalls', () => { 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: 100_000_000_000_000_000n, + nativeBalanceFloor: 0n, }) + }) + it('preserves the Filecoin FIL reserve when FIL funds a USDFC-only shortfall', async () => { mockPlan.mockResolvedValueOnce({ owner: OWNER, source: { @@ -252,8 +255,8 @@ describe('Squid payment shortfalls', () => { maxSourceAmount: 10n, slippage: 1, }) - await acquirePaymentShortfalls(input({ options: { ...input().options, fromChain: 'filecoin' } })) - expect(mockExecute.mock.calls[1]?.[0]).toMatchObject({ + 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, }) From 5daad40b4720a244019ce1f60b5d752b6345ca4f Mon Sep 17 00:00:00 2001 From: Mikers Date: Tue, 18 Aug 2026 10:11:22 -1000 Subject: [PATCH 12/26] refactor(test): remove nested environment ternary --- src/test/unit/squid-funding.test.ts | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/src/test/unit/squid-funding.test.ts b/src/test/unit/squid-funding.test.ts index cb5bb68a..e9ba2394 100644 --- a/src/test/unit/squid-funding.test.ts +++ b/src/test/unit/squid-funding.test.ts @@ -34,10 +34,10 @@ const CHAIN_IDS: Record = { let directory: string let marker: string const originalEnvironment = { - databasePath: process.env.DATABASE_PATH, - integratorId: process.env.SQUID_INTEGRATOR_ID, - network: process.env.NETWORK, - rpcUrl: process.env.RPC_URL, + 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 { @@ -104,15 +104,7 @@ describe('Squid payment shortfalls', () => { }) afterEach(async () => { - for (const [name, value] of Object.entries(originalEnvironment)) { - const key = - name === 'databasePath' - ? 'DATABASE_PATH' - : name === 'integratorId' - ? 'SQUID_INTEGRATOR_ID' - : name === 'network' - ? 'NETWORK' - : 'RPC_URL' + for (const [key, value] of Object.entries(originalEnvironment)) { if (value == null) delete process.env[key] else process.env[key] = value } From c588bda52bdff63d79d9602bea8203a6b5a1b767 Mon Sep 17 00:00:00 2001 From: Mikers Date: Tue, 18 Aug 2026 10:11:41 -1000 Subject: [PATCH 13/26] test(payments): use actual Squid funding module --- src/test/unit/run-fund-squid.test.ts | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/src/test/unit/run-fund-squid.test.ts b/src/test/unit/run-fund-squid.test.ts index 65a4cf95..34f8274f 100644 --- a/src/test/unit/run-fund-squid.test.ts +++ b/src/test/unit/run-fund-squid.test.ts @@ -16,7 +16,7 @@ vi.mock('@clack/prompts', () => ({ confirm: mockConfirm, isCancel: vi.fn(() => f vi.mock('../../core/synapse/index.js', () => ({ getClientAddress: vi.fn(() => '0x1111111111111111111111111111111111111111'), initializeSynapse: mockInitialize, - mainnet: { id: 314 }, + mainnet: { id: 314, contracts: { usdfc: { address: '0x80B98d3aa09ffff255c3ba4A241111Ff1262F045' } } }, })) vi.mock('../../utils/cli-auth.js', () => ({ parseCLIAuth: vi.fn(() => ({})), @@ -48,19 +48,10 @@ vi.mock('../../core/payments/index.js', () => ({ })) 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', () => ({ - acquirePaymentShortfalls: mockAcquire, - validateFundingSourceOptions: (options: Record) => { - const values = [options.fromChain, options.fromToken, options.maxSourceAmount, options.sourceRpcUrl] - const requested = values.some((value) => value != null && value.trim() !== '') - if (requested && values.some((value) => !value?.trim())) { - throw new Error( - 'Source acquisition requires --from-chain, --from-token, --max-source-amount, and --source-rpc-url together' - ) - } - return requested - }, -})) +vi.mock('../../payments/squid-funding.js', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, acquirePaymentShortfalls: mockAcquire } +}) const sourceOptions = { amount: '1', From 8586219069d4d48539516eb645a38c956238f1e0 Mon Sep 17 00:00:00 2001 From: Mikers Date: Tue, 18 Aug 2026 10:12:08 -1000 Subject: [PATCH 14/26] fix(payments): guard pending funding before planning --- src/payments/squid-funding.ts | 44 ++++++++++++++--------------- src/test/unit/squid-funding.test.ts | 4 ++- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/src/payments/squid-funding.ts b/src/payments/squid-funding.ts index 2cfeef7d..604c7c4c 100644 --- a/src/payments/squid-funding.ts +++ b/src/payments/squid-funding.ts @@ -182,6 +182,28 @@ export async function acquirePaymentShortfalls(input: AcquirePaymentShortfallsIn if (integratorId.trim() === '') throw new Error('SQUID_INTEGRATOR_ID is required') 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 + } + try { const clients = makeSourceClients(policy, sourceRpcUrl, input.options.privateKey, input.owner) const plan = await planSquidFunding( @@ -213,28 +235,6 @@ export async function acquirePaymentShortfalls(input: AcquirePaymentShortfallsIn maxNativeFee: policy.maxNativeFee, nativeCurrency: policy.chain.nativeCurrency, }) - await mkdir(dirname(path), { recursive: true }) - try { - await writeFile( - path, - JSON.stringify({ - owner: input.owner, - sourceChain: plan.source.chainId, - sourceToken: plan.source.token, - maxSourceAmount: plan.maxSourceAmount.toString(), - 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 - } - await executeSquidFunding( { plan, diff --git a/src/test/unit/squid-funding.test.ts b/src/test/unit/squid-funding.test.ts index e9ba2394..a5363c8a 100644 --- a/src/test/unit/squid-funding.test.ts +++ b/src/test/unit/squid-funding.test.ts @@ -157,7 +157,8 @@ describe('Squid payment shortfalls', () => { expect(JSON.parse(await readFile(marker, 'utf8'))).toMatchObject({ owner: OWNER, sourceChain: 42161, - maxSourceAmount: '10000000', + sourceToken: 'USDC', + maxSourceAmount: '10', }) }) @@ -165,6 +166,7 @@ describe('Squid payment shortfalls', () => { await writeFile(marker, '{}', { mode: 0o600 }) await expect(acquirePaymentShortfalls(input())).rejects.toThrow(marker) + expect(mockPlan).not.toHaveBeenCalled() expect(mockExecute).not.toHaveBeenCalled() }) From a17ebe528d29b48eadbfe7ec2ab088ef6e9d9b65 Mon Sep 17 00:00:00 2001 From: Mikers Date: Tue, 18 Aug 2026 10:12:22 -1000 Subject: [PATCH 15/26] test(payments): assert pending marker skips confirmation --- src/test/unit/squid-funding.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/test/unit/squid-funding.test.ts b/src/test/unit/squid-funding.test.ts index a5363c8a..337495a2 100644 --- a/src/test/unit/squid-funding.test.ts +++ b/src/test/unit/squid-funding.test.ts @@ -164,9 +164,11 @@ describe('Squid payment shortfalls', () => { it('uses atomic marker creation to block an ambiguous rerun', async () => { await writeFile(marker, '{}', { mode: 0o600 }) + const request = input() - await expect(acquirePaymentShortfalls(input())).rejects.toThrow(marker) + await expect(acquirePaymentShortfalls(request)).rejects.toThrow(marker) expect(mockPlan).not.toHaveBeenCalled() + expect(request.confirm).not.toHaveBeenCalled() expect(mockExecute).not.toHaveBeenCalled() }) From 047df3c14800cb5411d185cf569524da6b08b4e9 Mon Sep 17 00:00:00 2001 From: Mikers Date: Tue, 18 Aug 2026 10:12:46 -1000 Subject: [PATCH 16/26] fix(payments): distinguish funding marker failure phases --- src/payments/squid-funding.ts | 3 +++ src/test/unit/squid-funding.test.ts | 15 +++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/payments/squid-funding.ts b/src/payments/squid-funding.ts index 604c7c4c..98f71c09 100644 --- a/src/payments/squid-funding.ts +++ b/src/payments/squid-funding.ts @@ -204,6 +204,7 @@ export async function acquirePaymentShortfalls(input: AcquirePaymentShortfallsIn throw error } + let executionStarted = false try { const clients = makeSourceClients(policy, sourceRpcUrl, input.options.privateKey, input.owner) const plan = await planSquidFunding( @@ -235,6 +236,7 @@ export async function acquirePaymentShortfalls(input: AcquirePaymentShortfallsIn maxNativeFee: policy.maxNativeFee, nativeCurrency: policy.chain.nativeCurrency, }) + executionStarted = true await executeSquidFunding( { plan, @@ -257,6 +259,7 @@ export async function acquirePaymentShortfalls(input: AcquirePaymentShortfallsIn ) 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( diff --git a/src/test/unit/squid-funding.test.ts b/src/test/unit/squid-funding.test.ts index 337495a2..9f693c2c 100644 --- a/src/test/unit/squid-funding.test.ts +++ b/src/test/unit/squid-funding.test.ts @@ -172,6 +172,21 @@ describe('Squid payment shortfalls', () => { 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() From aca9bab3b9f9718cc5ea07fc32deacaaf879f8e2 Mon Sep 17 00:00:00 2001 From: Mikers Date: Tue, 18 Aug 2026 10:13:17 -1000 Subject: [PATCH 17/26] fix(payments): buffer Squid USDFC acquisition --- src/payments/fund.ts | 9 ++++++--- src/test/unit/run-fund-squid.test.ts | 8 ++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/payments/fund.ts b/src/payments/fund.ts index 0f3eff1e..e976ed9b 100644 --- a/src/payments/fund.ts +++ b/src/payments/fund.ts @@ -5,7 +5,7 @@ */ import { confirm, isCancel } from '@clack/prompts' -import type { Synapse } from '@filoz/synapse-sdk' +import { type Synapse, TIME_CONSTANTS } from '@filoz/synapse-sdk' import pc from 'picocolors' import { formatUnits, parseUnits } from 'viem' import { CliFatal, CliIncomplete, isCliFatal, isCliIncomplete, setIncompleteExitCode } from '../common/cli-errors.js' @@ -286,7 +286,10 @@ export async function runFund(options: FundOptions): Promise { 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 usdfcShortfall = preview.delta > 0n ? (preview.walletShortfall ?? 0n) : 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({ @@ -294,7 +297,7 @@ export async function runFund(options: FundOptions): Promise { owner: getClientAddress(synapse), filShortfall, usdfcShortfall, - requiredWalletUsdfc: preview.delta > 0n ? preview.delta : 0n, + requiredWalletUsdfc, options, confirm: async (summary) => { const spend = summary.quotes.reduce((total, quote) => total + quote.sourceAmount, 0n) diff --git a/src/test/unit/run-fund-squid.test.ts b/src/test/unit/run-fund-squid.test.ts index 34f8274f..25829bd3 100644 --- a/src/test/unit/run-fund-squid.test.ts +++ b/src/test/unit/run-fund-squid.test.ts @@ -87,7 +87,7 @@ describe('interactive Squid funding command', () => { payments: { accountSummary: vi.fn(async () => ({ funds: 0n })) }, }) mockGetPaymentStatus.mockResolvedValue({ filBalance: 90n, walletUsdfcBalance: 2n }) - mockCalculate.mockReturnValue({ delta: 10n, walletShortfall: 8n }) + mockCalculate.mockReturnValue({ delta: 10n, current: { spendRatePerEpoch: 1n } }) mockPlan.mockResolvedValue(refreshedPlan()) mockAcquire.mockImplementation(async (options) => { await options.confirm({ @@ -110,14 +110,14 @@ describe('interactive Squid funding command', () => { }) }) - it('passes the exact planner shortfalls, confirms, and replans after acquisition', async () => { + 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: 8n, - requiredWalletUsdfc: 10n, + usdfcShortfall: 128n, + requiredWalletUsdfc: 130n, options: expect.objectContaining({ privateKey: sourceOptions.privateKey }), }) ) From b210af725d65a1190e6fb25b4620d96d972c6784 Mon Sep 17 00:00:00 2001 From: Mikers Date: Tue, 18 Aug 2026 10:13:33 -1000 Subject: [PATCH 18/26] fix(payments): restrict Squid acquisition to deposits --- src/payments/fund.ts | 2 +- src/test/unit/run-fund-squid.test.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/payments/fund.ts b/src/payments/fund.ts index e976ed9b..c9c677e9 100644 --- a/src/payments/fund.ts +++ b/src/payments/fund.ts @@ -285,7 +285,7 @@ export async function runFund(options: FundOptions): Promise { ]) const preview = calculateFilecoinPayFundingPlan({ status, accountSummary, ...planOptions }) const filShortfall = - preview.delta !== 0n && status.filBalance < MIN_FIL_FOR_GAS ? MIN_FIL_FOR_GAS - status.filBalance : 0n + 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 = diff --git a/src/test/unit/run-fund-squid.test.ts b/src/test/unit/run-fund-squid.test.ts index 25829bd3..5ac09581 100644 --- a/src/test/unit/run-fund-squid.test.ts +++ b/src/test/unit/run-fund-squid.test.ts @@ -142,6 +142,15 @@ describe('interactive Squid funding command', () => { 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) From 8d4a2c800b3fa3ea20f0419faa42e1958a408335 Mon Sep 17 00:00:00 2001 From: Mikers Date: Tue, 18 Aug 2026 10:37:18 -1000 Subject: [PATCH 19/26] fix(payments): add bounded Squid price headroom --- src/payments/squid-funding.ts | 15 ++++++++++++++- src/test/unit/squid-funding.test.ts | 17 +++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/payments/squid-funding.ts b/src/payments/squid-funding.ts index 98f71c09..750ee84f 100644 --- a/src/payments/squid-funding.ts +++ b/src/payments/squid-funding.ts @@ -26,6 +26,7 @@ const SQUID_ROUTER = getAddress('0xce16F69375520ab01377ce7B88f5BA8C48F8D666') 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 @@ -207,7 +208,7 @@ export async function acquirePaymentShortfalls(input: AcquirePaymentShortfallsIn let executionStarted = false try { const clients = makeSourceClients(policy, sourceRpcUrl, input.options.privateKey, input.owner) - const plan = await planSquidFunding( + const planned = await planSquidFunding( { owner: input.owner, sourceChainId: policy.chain.id, @@ -218,6 +219,18 @@ export async function acquirePaymentShortfalls(input: AcquirePaymentShortfallsIn }, 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()) { diff --git a/src/test/unit/squid-funding.test.ts b/src/test/unit/squid-funding.test.ts index 9f693c2c..7b25c42c 100644 --- a/src/test/unit/squid-funding.test.ts +++ b/src/test/unit/squid-funding.test.ts @@ -133,6 +133,23 @@ describe('Squid payment shortfalls', () => { await expect(stat(marker)).rejects.toMatchObject({ code: 'ENOENT' }) }) + 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 From d2ac31532c56a0874c3e8a36b7c1853c716069b8 Mon Sep 17 00:00:00 2001 From: Mikers Date: Wed, 19 Aug 2026 09:47:20 -1000 Subject: [PATCH 20/26] chore(deps): use published Squid funding package --- package.json | 2 +- pnpm-lock.yaml | 33 ++++++++++++++--------------- pnpm-workspace.yaml | 3 ++- src/payments/squid-funding.ts | 4 ++-- src/test/unit/squid-funding.test.ts | 2 +- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/package.json b/package.json index 8b175c4f..7b8050b0 100644 --- a/package.json +++ b/package.json @@ -152,7 +152,7 @@ "picocolors": "^1.1.1", "pino": "^10.3.1", "semver": "^7.7.4", - "squid-evm-funding": "github:FilOzone/squid-evm-funding#v0.1.0", + "@filecoin-project/squid-evm-funding": "^0.1.7", "varint": "^6.0.0", "viem": "^2.48.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 531c4d51..47ac3c38 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.1.7 + version: 0.1.7(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)) @@ -92,9 +95,6 @@ importers: semver: specifier: ^7.7.4 version: 7.8.5 - squid-evm-funding: - specifier: github:FilOzone/squid-evm-funding#v0.1.0 - version: https://codeload.github.com/FilOzone/squid-evm-funding/tar.gz/b89293a964cc233909cac60899c9c1a0bc7a6df9(typescript@6.0.3)(zod@4.4.3) varint: specifier: ^6.0.0 version: 6.0.0 @@ -645,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.1.7': + resolution: {integrity: sha512-N3ml/Z5LCzTTj/9GVl1hcaZpE9buGT0IQKiX9gllIqvIUMvavnU5Iyi7KT5DAYSVXY9NVw/wsSMiKl8vC58TRA==} + engines: {node: '>=18'} + '@filoz/synapse-core@0.7.1': resolution: {integrity: sha512-4vZZJbumv64ZKR+BjfhLJvP57ga/GhSHy/k2jIWuXInXGwM5qPmKvaCS7CkpzQwiSIXahO161DBNRLVH8SrVfQ==} peerDependencies: @@ -2116,11 +2120,6 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} - squid-evm-funding@https://codeload.github.com/FilOzone/squid-evm-funding/tar.gz/b89293a964cc233909cac60899c9c1a0bc7a6df9: - resolution: {tarball: https://codeload.github.com/FilOzone/squid-evm-funding/tar.gz/b89293a964cc233909cac60899c9c1a0bc7a6df9} - version: 0.1.0 - engines: {node: '>=24'} - stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -2770,6 +2769,15 @@ snapshots: '@fastify/forwarded': 3.0.1 ipaddr.js: 2.3.0 + '@filecoin-project/squid-evm-funding@0.1.7(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 @@ -4686,15 +4694,6 @@ snapshots: split2@4.2.0: {} - squid-evm-funding@https://codeload.github.com/FilOzone/squid-evm-funding/tar.gz/b89293a964cc233909cac60899c9c1a0bc7a6df9(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 - stackback@0.0.2: {} std-env@4.0.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 641bd729..a70c518f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,9 +3,10 @@ packages: minimumReleaseAge: 10080 minimumReleaseAgeExclude: + - "@filecoin-project/squid-evm-funding" - "@filoz/*" onlyBuiltDependencies: - esbuild - node-datachannel - playwright - - squid-evm-funding + - "@filecoin-project/squid-evm-funding" diff --git a/src/payments/squid-funding.ts b/src/payments/squid-funding.ts index 750ee84f..aae76823 100644 --- a/src/payments/squid-funding.ts +++ b/src/payments/squid-funding.ts @@ -1,6 +1,5 @@ import { mkdir, unlink, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' -import type { Synapse } from '@filoz/synapse-sdk' import { type DestinationRequirement, executeSquidFunding, @@ -10,7 +9,8 @@ import { type SquidPublicClient, type SquidQuote, type SquidWalletClient, -} from 'squid-evm-funding' +} 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' diff --git a/src/test/unit/squid-funding.test.ts b/src/test/unit/squid-funding.test.ts index 7b25c42c..ac4a79ae 100644 --- a/src/test/unit/squid-funding.test.ts +++ b/src/test/unit/squid-funding.test.ts @@ -11,7 +11,7 @@ const { mockExecute, mockPlan } = vi.hoisted(() => ({ mockPlan: vi.fn(), })) -vi.mock('squid-evm-funding', () => ({ +vi.mock('@filecoin-project/squid-evm-funding', () => ({ NATIVE_TOKEN_ADDRESS: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', executeSquidFunding: mockExecute, planSquidFunding: mockPlan, From aaeec1990a85761ad599b5b4e0bd3fe5bf361352 Mon Sep 17 00:00:00 2001 From: Mikers Date: Wed, 19 Aug 2026 12:23:20 -1000 Subject: [PATCH 21/26] fix(payments): provide default Squid integrator ID --- README.md | 5 ++--- src/payments/squid-funding.ts | 4 ++-- src/test/unit/squid-funding.test.ts | 11 +++++++++++ src/utils/cli-options.ts | 2 +- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 4f1aeaf6..6240b177 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,6 @@ filecoin-pin add myfile.txt --network calibration 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 -export SQUID_INTEGRATOR_ID=your-integrator-id filecoin-pin payments fund --days 30 \ --from-chain arbitrum \ --from-token USDC \ @@ -218,7 +217,7 @@ filecoin-pin payments fund --days 30 \ --source-rpc-url https://your-arbitrum-rpc.example ``` -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. +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) @@ -291,7 +290,7 @@ RPC_URL=wss://... # Filecoin RPC endpoint (overrides NETWORK if spe # Calibration: wss://wss.calibration.node.glif.io/apigw/lotus/rpc/v1 # Optional - interactive Mainnet funding through Squid -SQUID_INTEGRATOR_ID=... # Squid-issued integration identifier +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 / diff --git a/src/payments/squid-funding.ts b/src/payments/squid-funding.ts index aae76823..4d6705ed 100644 --- a/src/payments/squid-funding.ts +++ b/src/payments/squid-funding.ts @@ -23,6 +23,7 @@ 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 @@ -179,8 +180,7 @@ export async function acquirePaymentShortfalls(input: AcquirePaymentShortfallsIn 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 ?? '' - if (integratorId.trim() === '') throw new Error('SQUID_INTEGRATOR_ID is required') + const integratorId = process.env.SQUID_INTEGRATOR_ID?.trim() || DEFAULT_SQUID_INTEGRATOR_ID const squid = { integratorId, fetch: fetchWithTimeout } await mkdir(dirname(path), { recursive: true }) diff --git a/src/test/unit/squid-funding.test.ts b/src/test/unit/squid-funding.test.ts index ac4a79ae..d18abc11 100644 --- a/src/test/unit/squid-funding.test.ts +++ b/src/test/unit/squid-funding.test.ts @@ -129,10 +129,21 @@ describe('Squid payment shortfalls', () => { 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') diff --git a/src/utils/cli-options.ts b/src/utils/cli-options.ts index 40b929b6..8fa9bb21 100644 --- a/src/utils/cli-options.ts +++ b/src/utils/cli-options.ts @@ -38,7 +38,7 @@ export function addFundingSourceOptions(command: Command): Command { .option('--source-rpc-url ', 'Source-chain RPC endpoint') .addHelpText( 'after', - '\nSquid funding requires Filecoin Mainnet, an interactive terminal, owner private-key auth, and SQUID_INTEGRATOR_ID.\n' + '\nSquid funding requires Filecoin Mainnet, an interactive terminal, and owner private-key auth.\n' ) } From 4dc8229a12ca7b1c939b7584e5e6e99d58431b95 Mon Sep 17 00:00:00 2001 From: Mikers Date: Thu, 20 Aug 2026 10:25:04 -1000 Subject: [PATCH 22/26] chore(deps): update Squid funding to 0.2.0 --- package.json | 2 +- pnpm-lock.yaml | 10 +++---- src/payments/squid-funding.ts | 4 +-- src/test/unit/squid-funding.test.ts | 45 +++++++++++++++-------------- 4 files changed, 31 insertions(+), 30 deletions(-) diff --git a/package.json b/package.json index 7b8050b0..0e429c61 100644 --- a/package.json +++ b/package.json @@ -152,7 +152,7 @@ "picocolors": "^1.1.1", "pino": "^10.3.1", "semver": "^7.7.4", - "@filecoin-project/squid-evm-funding": "^0.1.7", + "@filecoin-project/squid-evm-funding": "^0.2.0", "varint": "^6.0.0", "viem": "^2.48.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 47ac3c38..431c982a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: ^1.0.1 version: 1.5.1 '@filecoin-project/squid-evm-funding': - specifier: ^0.1.7 - version: 0.1.7(typescript@6.0.3)(zod@4.4.3) + specifier: ^0.2.0 + version: 0.2.0(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)) @@ -645,8 +645,8 @@ packages: '@fastify/proxy-addr@5.1.0': resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} - '@filecoin-project/squid-evm-funding@0.1.7': - resolution: {integrity: sha512-N3ml/Z5LCzTTj/9GVl1hcaZpE9buGT0IQKiX9gllIqvIUMvavnU5Iyi7KT5DAYSVXY9NVw/wsSMiKl8vC58TRA==} + '@filecoin-project/squid-evm-funding@0.2.0': + resolution: {integrity: sha512-ZVgH7nNyvM5Tx9d95WyTNKz/pSJYyj5mhTSBv2ATZD1nXhR0JN4Xt8o0p3oCy0yHe65zlvWz6aH1Ca+6/yNRpQ==} engines: {node: '>=18'} '@filoz/synapse-core@0.7.1': @@ -2769,7 +2769,7 @@ snapshots: '@fastify/forwarded': 3.0.1 ipaddr.js: 2.3.0 - '@filecoin-project/squid-evm-funding@0.1.7(typescript@6.0.3)(zod@4.4.3)': + '@filecoin-project/squid-evm-funding@0.2.0(typescript@6.0.3)(zod@4.4.3)': dependencies: viem: 2.55.10(typescript@6.0.3)(zod@4.4.3) transitivePeerDependencies: diff --git a/src/payments/squid-funding.ts b/src/payments/squid-funding.ts index 4d6705ed..44679fd1 100644 --- a/src/payments/squid-funding.ts +++ b/src/payments/squid-funding.ts @@ -6,8 +6,8 @@ import { NATIVE_TOKEN_ADDRESS, planSquidFunding, type SourceToken, + type SquidPriceQuote, type SquidPublicClient, - type SquidQuote, type SquidWalletClient, } from '@filecoin-project/squid-evm-funding' import type { Synapse } from '@filoz/synapse-sdk' @@ -51,7 +51,7 @@ const SOURCE_POLICIES: readonly SourcePolicy[] = [ interface PaymentAcquisitionSummary { source: SourceToken sourceChainName: string - quotes: readonly SquidQuote[] + quotes: readonly SquidPriceQuote[] maxSourceAmount: bigint maxNativeFee: bigint nativeCurrency: { symbol: string; decimals: number } diff --git a/src/test/unit/squid-funding.test.ts b/src/test/unit/squid-funding.test.ts index d18abc11..fadbffb0 100644 --- a/src/test/unit/squid-funding.test.ts +++ b/src/test/unit/squid-funding.test.ts @@ -1,6 +1,7 @@ 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' @@ -19,7 +20,6 @@ vi.mock('@filecoin-project/squid-evm-funding', () => ({ const PRIVATE_KEY = `0x${'01'.padStart(64, '0')}` as Hex const OWNER = privateKeyToAccount(PRIVATE_KEY).address -const ROUTER = '0xce16F69375520ab01377ce7B88f5BA8C48F8D666' const CHAIN_IDS: Record = { filecoin: 314, arbitrum: 42161, @@ -79,27 +79,28 @@ describe('Squid payment shortfalls', () => { delete process.env.NETWORK delete process.env.RPC_URL - mockPlan.mockImplementation(async ({ owner, sourceChainId, requirements, maxSourceAmount }) => ({ - owner, - source: { - chainId: sourceChainId, - token: '0x1111111111111111111111111111111111111111', - symbol: 'USDC', - decimals: 6, - }, - quotes: requirements.map((requirement: Record, index: number) => ({ - id: `quote-${index}`, - requirement, - sourceAmount: 1_000_000n, - destinationAmount: requirement.amount, - target: ROUTER, - data: '0x12', - value: 0n, - expiresAt: 2_000_000_000, - })), - maxSourceAmount: BigInt(maxSourceAmount) * 1_000_000n, - slippage: 1, - })) + 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: [] }) }) From 75b906a1cd871e553a0503e40b934d215b9c304a Mon Sep 17 00:00:00 2001 From: Mikers Date: Thu, 20 Aug 2026 11:10:06 -1000 Subject: [PATCH 23/26] chore(deps): update Squid funding to 0.2.1 --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 0e429c61..ef9c6164 100644 --- a/package.json +++ b/package.json @@ -152,7 +152,7 @@ "picocolors": "^1.1.1", "pino": "^10.3.1", "semver": "^7.7.4", - "@filecoin-project/squid-evm-funding": "^0.2.0", + "@filecoin-project/squid-evm-funding": "^0.2.1", "varint": "^6.0.0", "viem": "^2.48.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 431c982a..e6e4cb96 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: ^1.0.1 version: 1.5.1 '@filecoin-project/squid-evm-funding': - specifier: ^0.2.0 - version: 0.2.0(typescript@6.0.3)(zod@4.4.3) + specifier: ^0.2.1 + version: 0.2.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)) @@ -645,8 +645,8 @@ packages: '@fastify/proxy-addr@5.1.0': resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} - '@filecoin-project/squid-evm-funding@0.2.0': - resolution: {integrity: sha512-ZVgH7nNyvM5Tx9d95WyTNKz/pSJYyj5mhTSBv2ATZD1nXhR0JN4Xt8o0p3oCy0yHe65zlvWz6aH1Ca+6/yNRpQ==} + '@filecoin-project/squid-evm-funding@0.2.1': + resolution: {integrity: sha512-mC/XJAoS4lPiLOUxmx1x3wuJRltD3K84bAbr69ODVBIAjtuZzxI1JwfTbGcFgRLXUW1Jbrx/QVCSA7KkmQIq2A==} engines: {node: '>=18'} '@filoz/synapse-core@0.7.1': @@ -2769,7 +2769,7 @@ snapshots: '@fastify/forwarded': 3.0.1 ipaddr.js: 2.3.0 - '@filecoin-project/squid-evm-funding@0.2.0(typescript@6.0.3)(zod@4.4.3)': + '@filecoin-project/squid-evm-funding@0.2.1(typescript@6.0.3)(zod@4.4.3)': dependencies: viem: 2.55.10(typescript@6.0.3)(zod@4.4.3) transitivePeerDependencies: From 87d73f89cd7764847b45c629d864afad51fb7403 Mon Sep 17 00:00:00 2001 From: Mikers Date: Thu, 20 Aug 2026 12:25:35 -1000 Subject: [PATCH 24/26] chore(payments): update Squid funding to 0.2.2 --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index ef9c6164..8c298da2 100644 --- a/package.json +++ b/package.json @@ -152,7 +152,7 @@ "picocolors": "^1.1.1", "pino": "^10.3.1", "semver": "^7.7.4", - "@filecoin-project/squid-evm-funding": "^0.2.1", + "@filecoin-project/squid-evm-funding": "^0.2.2", "varint": "^6.0.0", "viem": "^2.48.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6e4cb96..64b6a88d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: ^1.0.1 version: 1.5.1 '@filecoin-project/squid-evm-funding': - specifier: ^0.2.1 - version: 0.2.1(typescript@6.0.3)(zod@4.4.3) + specifier: ^0.2.2 + version: 0.2.2(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)) @@ -645,8 +645,8 @@ packages: '@fastify/proxy-addr@5.1.0': resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} - '@filecoin-project/squid-evm-funding@0.2.1': - resolution: {integrity: sha512-mC/XJAoS4lPiLOUxmx1x3wuJRltD3K84bAbr69ODVBIAjtuZzxI1JwfTbGcFgRLXUW1Jbrx/QVCSA7KkmQIq2A==} + '@filecoin-project/squid-evm-funding@0.2.2': + resolution: {integrity: sha512-s6w9EFANUWMkVBLG58T2F0w4vyOhfBWbRuV8cJDGwyu2ck57TgzY9740XfaENlyi5CSGwwBdeBtUVX+oIvnugg==} engines: {node: '>=18'} '@filoz/synapse-core@0.7.1': @@ -2769,7 +2769,7 @@ snapshots: '@fastify/forwarded': 3.0.1 ipaddr.js: 2.3.0 - '@filecoin-project/squid-evm-funding@0.2.1(typescript@6.0.3)(zod@4.4.3)': + '@filecoin-project/squid-evm-funding@0.2.2(typescript@6.0.3)(zod@4.4.3)': dependencies: viem: 2.55.10(typescript@6.0.3)(zod@4.4.3) transitivePeerDependencies: From 44ba2877f65ab6b575387b9c94c88f31084235e3 Mon Sep 17 00:00:00 2001 From: Mikers Date: Fri, 21 Aug 2026 14:45:23 -1000 Subject: [PATCH 25/26] chore: update Squid funding package --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 8c298da2..8a82ad36 100644 --- a/package.json +++ b/package.json @@ -152,7 +152,7 @@ "picocolors": "^1.1.1", "pino": "^10.3.1", "semver": "^7.7.4", - "@filecoin-project/squid-evm-funding": "^0.2.2", + "@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 64b6a88d..90921ece 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: ^1.0.1 version: 1.5.1 '@filecoin-project/squid-evm-funding': - specifier: ^0.2.2 - version: 0.2.2(typescript@6.0.3)(zod@4.4.3) + 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)) @@ -645,8 +645,8 @@ packages: '@fastify/proxy-addr@5.1.0': resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} - '@filecoin-project/squid-evm-funding@0.2.2': - resolution: {integrity: sha512-s6w9EFANUWMkVBLG58T2F0w4vyOhfBWbRuV8cJDGwyu2ck57TgzY9740XfaENlyi5CSGwwBdeBtUVX+oIvnugg==} + '@filecoin-project/squid-evm-funding@0.3.1': + resolution: {integrity: sha512-MKipKkSruhTTNNIzQbXgkNUgP89Tx8Y+U2JYAs15xTKY7famHC/lv+WyHzG6PcFCvvRo9CguWPRX1iuOM8ffGw==} engines: {node: '>=18'} '@filoz/synapse-core@0.7.1': @@ -2769,7 +2769,7 @@ snapshots: '@fastify/forwarded': 3.0.1 ipaddr.js: 2.3.0 - '@filecoin-project/squid-evm-funding@0.2.2(typescript@6.0.3)(zod@4.4.3)': + '@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: From b8659f33ce54e7d53f47c7c2838ac1b1da5f2b04 Mon Sep 17 00:00:00 2001 From: Mikers Date: Fri, 21 Aug 2026 14:45:23 -1000 Subject: [PATCH 26/26] fix: disclose Squid native route fees --- src/payments/fund.ts | 12 ++++++++- src/test/unit/run-fund-squid.test.ts | 37 +++++++++++++++++++++++++--- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/payments/fund.ts b/src/payments/fund.ts index c9c677e9..cdc15abd 100644 --- a/src/payments/fund.ts +++ b/src/payments/fund.ts @@ -5,6 +5,7 @@ */ import { confirm, isCancel } from '@clack/prompts' +import { NATIVE_TOKEN_ADDRESS } from '@filecoin-project/squid-evm-funding' import { type Synapse, TIME_CONSTANTS } from '@filoz/synapse-sdk' import pc from 'picocolors' import { formatUnits, parseUnits } from 'viem' @@ -301,6 +302,15 @@ export async function runFund(options: FundOptions): Promise { 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) => @@ -308,7 +318,7 @@ export async function runFund(options: FundOptions): Promise { ) .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}; buffered network-fee limit: ${formatUnits(summary.maxNativeFee, summary.nativeCurrency.decimals)} ${summary.nativeCurrency.symbol}; final network fees may vary)?`, + 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') diff --git a/src/test/unit/run-fund-squid.test.ts b/src/test/unit/run-fund-squid.test.ts index 5ac09581..bc212e2f 100644 --- a/src/test/unit/run-fund-squid.test.ts +++ b/src/test/unit/run-fund-squid.test.ts @@ -100,10 +100,42 @@ describe('interactive Squid funding command', () => { { 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, + }, + }, + ], }, ], }) @@ -125,9 +157,8 @@ describe('interactive Squid funding command', () => { 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( - 'buffered network-fee limit: 0.03 ETH; final network fees may vary' - ) + 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() })