From 831c922c7e3ec138a12aea25b0fa93539562993c Mon Sep 17 00:00:00 2001 From: Mikers Date: Thu, 30 Jul 2026 14:15:40 -1000 Subject: [PATCH 1/2] refactor: simplify Squid funding API --- README.md | 192 +++----- package.json | 2 +- scripts/pack-check.mjs | 135 ------ src/catalog.ts | 148 ++----- src/execution.test.ts | 774 +++++++++++++++++++------------- src/execution.ts | 291 ++++++------- src/index.test.ts | 969 +++++++++++------------------------------ src/index.ts | 12 +- src/planner.ts | 61 ++- src/squid.ts | 140 +++--- src/types.ts | 44 +- 11 files changed, 1058 insertions(+), 1710 deletions(-) delete mode 100644 scripts/pack-check.mjs diff --git a/README.md b/README.md index ac39bae..af2f810 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,8 @@ # squid-evm-funding Small TypeScript helpers for planning and executing capped EVM token routes -through [Squid](https://www.squidrouter.com/). The package uses Squid's v2 HTTP -API, built-in `fetch`, and caller-created [viem](https://viem.sh/) clients. It -does not accept private keys, choose a source token, read balances to recommend -a source, or expose a Filecoin-specific API. +through [Squid](https://www.squidrouter.com/). The package uses Squid's v2 API, +built-in `fetch`, and caller-created [viem](https://viem.sh/) clients. Requires Node.js 24 or newer. @@ -14,28 +12,21 @@ pnpm add squid-evm-funding viem ## Terms -- **Requirement:** one token amount that must arrive at a recipient on a +- **Requirement:** a token amount that must arrive at a recipient on one destination EVM chain. -- **Source:** the chain and token the caller permits Squid to spend. -- **Quote:** one validated, fixed-input Squid route for one requirement. -- **Execution:** one guarded call that plans, validates, broadcasts, verifies, - and returns transaction hashes. +- **Source:** the caller-selected chain and Squid-catalog token to spend. +- **Plan:** validated fixed-input routes that fit one source-token cap. +- **Execution:** refreshed, guarded transactions followed by receipt, Squid + status, and destination-balance checks. -## Plan and execute a route +## Usage -The caller owns the account, RPC URLs, integrator ID, trust policy, caps, and -trusted contract addresses. This example uses Arbitrum USDC as the source and Filecoin -USDFC as the destination. Source selection is limited to Filecoin, Arbitrum, -Ethereum, Base, Optimism, Polygon, Avalanche, and BNB Chain. +The public API has two operations: `planSquidFunding` and +`executeSquidFunding`. The caller owns the account, RPC URLs, trusted Squid +addresses, fee policy, and integrator ID. ```ts -import { - executeSquidFunding, - fetchSquidCatalog, - planSquidFunding, - quoteSquidRoute, - resolveSourceToken, -} from "squid-evm-funding" +import { executeSquidFunding, planSquidFunding } from "squid-evm-funding" import { createPublicClient, createWalletClient, @@ -57,151 +48,82 @@ declare const squidRouterAddress: Address declare const squidApprovalSpender: Address const account = privateKeyToAccount(sourcePrivateKey) -const sourcePublicClient = createPublicClient({ +const publicClient = createPublicClient({ chain: arbitrum, transport: http(sourceRpcUrl), }) -const sourceWalletClient = createWalletClient({ +const walletClient = createWalletClient({ account, chain: arbitrum, transport: http(sourceRpcUrl), }) -const filecoinPublicClient = createPublicClient({ +const destinationClient = createPublicClient({ chain: filecoin, transport: http(filecoinRpcUrl), }) - const squid = { integratorId } -const catalog = await fetchSquidCatalog(squid) -const source = resolveSourceToken(catalog, arbitrum.id, "USDC") -const requirements = [ - { - id: "filecoin-pay-shortfall", - chainId: filecoin.id, - token: usdfcAddress, - amount: parseUnits("2", 18), - recipient: account.address, - }, -] -const maxSourceAmount = parseUnits("3", source.decimals) -const slippage = 1 -const quotes = await planSquidFunding( + +const plan = await planSquidFunding( { owner: account.address, - source, - requirements, - maxSourceAmount, - slippage, + sourceChainId: arbitrum.id, + sourceToken: "USDC", // "native", an address, or an unambiguous symbol + requirements: [ + { + id: "filecoin-pay-shortfall", + chainId: filecoin.id, + token: usdfcAddress, + amount: parseUnits("2", 18), + recipient: account.address, + }, + ], + maxSourceAmount: "3", + slippage: 1, }, squid, ) const result = await executeSquidFunding( { - account: account.address, - source, - quotes, - maxSourceAmount, + plan, maxNativeFee: parseEther("0.005"), sourceBalanceFloor: 0n, nativeBalanceFloor: parseEther("0.001"), - // Resolve these independently from an approved deployment, not from a quote. trustedTarget: squidRouterAddress, trustedSpender: squidApprovalSpender, feeMode: "standard", maxPollAttempts: 60, pollIntervalMs: 5_000, }, - { - publicClient: sourcePublicClient, - walletClient: sourceWalletClient, - destinationClient: (chainId) => { - if (chainId !== filecoin.id) - throw new Error(`unsupported destination ${chainId}`) - return filecoinPublicClient - }, - refreshQuote: (planned) => - quoteSquidRoute( - { - owner: account.address, - source: planned.source, - requirement: planned.requirement, - sourceAmount: planned.sourceAmount, - slippage, - }, - squid, - ), - squidStatusOptions: squid, - }, + { publicClient, walletClient, destinationClient, squid }, ) ``` -`sourcePrivateKey`, RPC URLs, token and trusted contract addresses, and -`integratorId` above are application-owned configuration. The package never -reads them from the environment. - -## Public API - -- `fetchSquidCatalog` fetches Squid's EVM chain and token catalogs; - `parseSquidCatalog` validates previously fetched catalog payloads. -- `resolveSourceToken` selects `native`, an address, or an unambiguous symbol on - a caller-selected source chain. `NATIVE_TOKEN_ADDRESS` is the normalized - native-token sentinel. -- `quoteSquidRoute` validates one fixed-input route and returns its transaction, - source identity, destination minimum, request identity, and expiry. - `SquidMinimumAmountError` identifies an explicit provider minimum. -- `planSquidFunding` quotes each requirement, proportionally reduces successful - seed quotes, and shares one `maxSourceAmount` across every leg. It performs at - most four quotes per leg and fails when the cap, provider minimum, expiry, or - convergence bound cannot be satisfied. -- `fetchSquidStatus` fetches one route status; `parseSquidStatus` normalizes a - previously fetched Squid status response. -- `executeSquidFunding` refreshes and validates routes, sends exact ERC-20 - approvals when needed, sends route transactions, polls bounded completion, - and confirms the destination balance. It returns source amount, total native - fee, and route transaction hashes. - -The exported types are `DestinationRequirement`, `SourceToken`, `SquidCatalog`, -`SquidChain`, `SquidClientOptions`, `SquidExecutionResult`, -`SquidPublicClient`, `SquidQuote`, -`SquidStatusReference`, and `SquidWalletClient`. Applications can therefore -implement clients and status callbacks without importing -internal modules. +The package never reads private keys, RPC URLs, or the integrator ID from the +environment. It fetches Squid's token catalog during planning, so source tokens +are not limited to a package-maintained token list. The host remains responsible +for deciding which EVM source chains it supports. ## Execution constraints -Execution is deliberately opt-in and fail-closed: - -- All planned source amounts must fit `maxSourceAmount`. `maxNativeFee` bounds - the cumulative fee commitments for approvals and routes, while optional - source and native balance floors preserve caller-selected reserves. -- `trustedTarget` and `trustedSpender` are caller policy. Resolve them from an - independently approved Squid deployment or allowlist; do not trust an address - merely because the same route response supplied it. -- The wallet and public client must match the source chain and account. The - destination client must match each requirement. Execution refuses to start a - send while the account has a pending source-chain transaction. -- ERC-20 allowances are reset when required and set to the exact refreshed - source amount. Route identity and expiry are checked again at the send - boundary. -- `maxPollAttempts` and `pollIntervalMs` bound status polling. Provider and RPC - request timeouts remain the caller's responsibility. - -For ordinary EVM chains, use `feeMode: "standard"`. For OP Stack chains, use -`feeMode: "op-stack"`, extend the source public client with `estimateTotalFee`, -and provide `opStackFeeBuffer`. `estimateTotalFee` must include L2 execution, -L1 data, and operator fee components for every approval and route transaction. -The buffer must not reduce that estimate and should conservatively cover fee -movement before inclusion. Execution fails closed if either total-fee -accounting or the buffer is missing. - -Provide either a custom `status` callback or `squidStatusOptions`. A callable -`status` callback takes precedence when both are present. Otherwise valid Squid -options are required and the built-in status request is used. - -## Interrupted executions - -The executor is intentionally stateless. An interruption can leave the -destination ambiguous, so the host must record one coarse in-progress marker -around the call and require manual verification before another run. It never -replays, reconstructs, or resumes a prior transaction. +Execution fails closed unless: + +- planned source amounts fit `maxSourceAmount`; +- RPC and account-bound wallet clients match the source chain and owner; +- every requirement uses the destination client's chain; +- refreshed routes preserve source amount and destination identity, remain + unexpired, and use caller-trusted target and spender addresses; +- source and native balances preserve the optional caller-selected floors; +- no pending transaction or nonce change makes the next send ambiguous; +- exact ERC-20 allowances, source receipts, Squid success, and destination + balance arrival are verified. + +`maxNativeFee` bounds cumulative fee commitments for approvals and routes. For +OP Stack chains, use `feeMode: "op-stack"`, provide an `estimateTotalFee` +extension that includes execution, L1 data, and operator fees, and supply a +conservative `opStackFeeBuffer`. Execution fails if complete fee accounting is +unavailable. + +The executor is stateless. A host that must block a rerun after interruption +should place one coarse marker around `executeSquidFunding` and require manual +verification before removing an ambiguous marker. diff --git a/package.json b/package.json index 933af12..2eb46c2 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "lint": "biome check .", "test": "vitest run", "prepare": "node scripts/clean-dist.mjs && tsc", - "pack:check": "node scripts/pack-check.mjs" + "pack:check": "pnpm pack --dry-run" }, "dependencies": { "viem": "^2.48.3" diff --git a/scripts/pack-check.mjs b/scripts/pack-check.mjs deleted file mode 100644 index fa5b575..0000000 --- a/scripts/pack-check.mjs +++ /dev/null @@ -1,135 +0,0 @@ -import { execFileSync } from "node:child_process" -import { mkdir, mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises" -import { tmpdir } from "node:os" -import { basename, dirname, join } from "node:path" -import { pathToFileURL } from "node:url" - -function pnpm(args, options = {}) { - const executable = process.env.npm_execpath - if (executable != null) - return execFileSync(process.execPath, [executable, ...args], options) - return execFileSync( - process.platform === "win32" ? "pnpm.cmd" : "pnpm", - args, - options, - ) -} - -function npm(args, options = {}) { - if (process.platform !== "win32") return execFileSync("npm", args, options) - const npmCli = join( - dirname(process.execPath), - "node_modules", - "npm", - "bin", - "npm-cli.js", - ) - return execFileSync(process.execPath, [npmCli, ...args], options) -} - -const temporaryRoot = await mkdtemp(join(tmpdir(), "squid-evm-funding-pack-")) -const packDirectory = join(temporaryRoot, "pack") -const consumerDirectory = join(temporaryRoot, "consumer") -const gitConsumerDirectory = join(temporaryRoot, "git-consumer") -const expectedFiles = [ - "LICENSE", - "README.md", - "dist/catalog.d.ts", - "dist/catalog.js", - "dist/execution.d.ts", - "dist/execution.js", - "dist/index.d.ts", - "dist/index.js", - "dist/planner.d.ts", - "dist/planner.js", - "dist/squid.d.ts", - "dist/squid.js", - "dist/types.d.ts", - "dist/types.js", - "package.json", -].sort() - -async function listFiles(directory, prefix = "") { - const files = [] - for (const entry of await readdir(directory, { withFileTypes: true })) { - const relativePath = prefix === "" ? entry.name : `${prefix}/${entry.name}` - if (entry.isDirectory()) - files.push( - ...(await listFiles(join(directory, entry.name), relativePath)), - ) - else files.push(relativePath) - } - return files -} - -function assertRootExports(directory) { - execFileSync( - process.execPath, - [ - "--input-type=module", - "--eval", - [ - 'const packageRoot = await import("squid-evm-funding")', - 'const expected = ["NATIVE_TOKEN_ADDRESS", "SquidMinimumAmountError", "executeSquidFunding", "fetchSquidCatalog", "fetchSquidStatus", "parseSquidCatalog", "parseSquidStatus", "planSquidFunding", "quoteSquidRoute", "resolveSourceToken"]', - "const actual = Object.keys(packageRoot).sort()", - 'if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new Error("unexpected root exports: " + actual.join(", "))', - ].join("\n"), - ], - { cwd: directory, stdio: "inherit" }, - ) -} - -try { - await Promise.all([ - mkdir(packDirectory), - mkdir(consumerDirectory), - mkdir(gitConsumerDirectory), - ]) - pnpm(["pack", "--pack-destination", packDirectory], { stdio: "inherit" }) - const archives = (await readdir(packDirectory)).filter((file) => - file.endsWith(".tgz"), - ) - if (archives.length !== 1) - throw new Error(`expected one packed archive, found ${archives.length}`) - const archive = join(packDirectory, archives[0]) - await writeFile( - join(consumerDirectory, "package.json"), - JSON.stringify({ private: true, type: "module" }), - ) - pnpm(["add", "--save-exact", archive], { - cwd: consumerDirectory, - stdio: "inherit", - }) - assertRootExports(consumerDirectory) - const installedPackage = join( - consumerDirectory, - "node_modules", - "squid-evm-funding", - ) - const packedFiles = (await listFiles(installedPackage)).sort() - if (JSON.stringify(packedFiles) !== JSON.stringify(expectedFiles)) - throw new Error(`unexpected packed files: ${packedFiles.join(", ")}`) - await writeFile( - join(gitConsumerDirectory, "package.json"), - JSON.stringify({ private: true, type: "module" }), - ) - npm(["install", `git+${pathToFileURL(process.cwd()).href}`], { - cwd: gitConsumerDirectory, - stdio: "inherit", - }) - assertRootExports(gitConsumerDirectory) - const archiveSize = (await stat(archive)).size - console.log( - JSON.stringify( - { - archive: basename(archive), - packedBytes: archiveSize, - files: packedFiles, - }, - null, - 2, - ), - ) -} finally { - await rm(temporaryRoot, { recursive: true, force: true }) -} diff --git a/src/catalog.ts b/src/catalog.ts index e4acc56..b520a94 100644 --- a/src/catalog.ts +++ b/src/catalog.ts @@ -1,17 +1,5 @@ import type { Address } from "viem" -import { - NATIVE_TOKEN_ADDRESS, - type SourceToken, - type SquidCatalog, - type SquidChain, -} from "./types.js" - -interface ChainWire { - chainId?: string | number - networkName?: string - type?: string - nativeCurrency?: { symbol?: string; decimals?: number } -} +import { NATIVE_TOKEN_ADDRESS, type SourceToken } from "./types.js" interface TokenWire { chainId?: string | number @@ -20,31 +8,8 @@ interface TokenWire { decimals?: number } -const SUPPORTED_SOURCE_CHAIN_IDS = new Set([ - 314, // Filecoin - 42161, // Arbitrum - 1, // Ethereum - 8453, // Base - 10, // Optimism - 137, // Polygon - 43114, // Avalanche - 56, // BNB Chain -]) - function invalid(message: string): never { - throw new Error(`Invalid Squid catalog: ${message}`) -} - -function chainId(value: unknown, label: string): number { - const parsed = - typeof value === "string" && /^\d+$/.test(value) ? Number(value) : value - if ( - typeof parsed !== "number" || - !Number.isSafeInteger(parsed) || - parsed <= 0 - ) - invalid(`${label} has an invalid chainId`) - return parsed + throw new Error(`Invalid Squid token catalog: ${message}`) } function address(value: unknown, label: string): Address { @@ -53,109 +18,88 @@ function address(value: unknown, label: string): Address { return value.toLowerCase() as Address } -function decimals(value: unknown, label: string): number { +export function parseSourceTokens( + response: unknown, + sourceChainId: number, +): SourceToken[] { if ( - typeof value !== "number" || - !Number.isSafeInteger(value) || - value < 0 || - value > 255 + response == null || + typeof response !== "object" || + !Array.isArray((response as { tokens?: unknown }).tokens) ) - invalid(`${label} has invalid decimals`) - return value -} + invalid("tokens must be an array") -/** Parse all EVM catalog entries. A malformed EVM entry is rejected before it can authorize a route. */ -export function parseSquidCatalog( - chainsResponse: unknown, - tokensResponse: unknown, -): SquidCatalog { - if (!Array.isArray(chainsResponse) || !Array.isArray(tokensResponse)) - invalid("chains and tokens must be arrays") - const chains = new Map() - for (const raw of chainsResponse as ChainWire[]) { - if (raw == null || typeof raw !== "object" || raw.type !== "evm") continue - const id = chainId(raw.chainId, "chain") - if (!SUPPORTED_SOURCE_CHAIN_IDS.has(id)) continue - if (chains.has(id)) invalid(`chain ${id} is duplicated`) - if (typeof raw.networkName !== "string" || raw.networkName.trim() === "") - invalid(`chain ${id} is missing networkName`) - if ( - typeof raw.nativeCurrency?.symbol !== "string" || - raw.nativeCurrency.symbol.trim() === "" - ) - invalid(`chain ${id} is missing native symbol`) - decimals(raw.nativeCurrency.decimals, `chain ${id}`) - chains.set(id, { chainId: id, networkName: raw.networkName.trim() }) - } const tokens: SourceToken[] = [] const identities = new Set() - for (const raw of tokensResponse as TokenWire[]) { + for (const raw of (response as { tokens: TokenWire[] }).tokens) { if (raw == null || typeof raw !== "object") continue - const id = + const chainId = typeof raw.chainId === "string" && /^\d+$/.test(raw.chainId) ? Number(raw.chainId) : raw.chainId - if (typeof id !== "number" || !Number.isSafeInteger(id) || id <= 0) continue - const chain = chains.get(id) - if (chain == null) continue + if (chainId !== sourceChainId) continue if (typeof raw.symbol !== "string" || raw.symbol.trim() === "") - invalid(`token on ${id} is missing symbol`) - const token = address(raw.address, `token ${raw.symbol} on ${id}`) - const native = token === NATIVE_TOKEN_ADDRESS - const tokenDecimals = decimals(raw.decimals, `token ${raw.symbol} on ${id}`) - const identity = `${id}:${token}` - if (identities.has(identity)) - invalid(`token ${token} on ${id} is duplicated`) - identities.add(identity) + invalid(`token on ${sourceChainId} is missing symbol`) + if ( + typeof raw.decimals !== "number" || + !Number.isSafeInteger(raw.decimals) || + raw.decimals < 0 || + raw.decimals > 255 + ) + invalid(`token ${raw.symbol} on ${sourceChainId} has invalid decimals`) + const token = address( + raw.address, + `token ${raw.symbol} on ${sourceChainId}`, + ) + if (identities.has(token)) + invalid(`token ${token} on ${sourceChainId} is duplicated`) + identities.add(token) tokens.push({ - chain, + chainId: sourceChainId, token, symbol: raw.symbol.trim(), - decimals: tokenDecimals, - native, + decimals: raw.decimals, }) } - return { chains, tokens } + return tokens } export function resolveSourceToken( - catalog: SquidCatalog, - chainId: number, + tokens: readonly SourceToken[], + sourceChainId: number, selector: string, ): SourceToken { - const chain = catalog.chains.get(chainId) - if (chain == null) - throw new Error(`Squid does not support EVM chain ${chainId}`) - const candidates = catalog.tokens.filter( - (token) => token.chain.chainId === chainId, - ) - if (selector.trim().toLowerCase() === "native") { - const native = candidates.filter((token) => token.native) + const candidates = tokens.filter((token) => token.chainId === sourceChainId) + const normalized = selector.trim().toLowerCase() + if (normalized === "native") { + const native = candidates.filter( + (token) => token.token === NATIVE_TOKEN_ADDRESS, + ) if (native.length !== 1) throw new Error( - `Squid catalog has no unambiguous native token for chain ${chainId}`, + `Squid token catalog has no unambiguous native token for chain ${sourceChainId}`, ) return native[0] as SourceToken } - if (/^0x[0-9a-fA-F]{40}$/.test(selector.trim())) { + if (/^0x[0-9a-fA-F]{40}$/.test(normalized)) { const exact = candidates.find( - (token) => token.token.toLowerCase() === selector.trim().toLowerCase(), + (token) => token.token.toLowerCase() === normalized, ) if (exact == null) throw new Error( - `Source token address is not supported on chain ${chainId}`, + `Source token address is not supported on chain ${sourceChainId}`, ) return exact } const matches = candidates.filter( - (token) => token.symbol.toLowerCase() === selector.trim().toLowerCase(), + (token) => token.symbol.toLowerCase() === normalized, ) if (matches.length === 1) return matches[0] as SourceToken if (matches.length > 1) throw new Error( - `Source token symbol ${selector} is ambiguous on chain ${chainId}; use its address`, + `Source token symbol ${selector} is ambiguous on chain ${sourceChainId}; use its address`, ) throw new Error( - `Source token ${selector} is not supported on chain ${chainId}`, + `Source token ${selector} is not supported on chain ${sourceChainId}`, ) } diff --git a/src/execution.test.ts b/src/execution.test.ts index dc25f20..249e25e 100644 --- a/src/execution.test.ts +++ b/src/execution.test.ts @@ -2,12 +2,14 @@ import { type Account, decodeFunctionData, erc20Abi } from "viem" import { describe, expect, it } from "vitest" import { executeSquidFunding, + NATIVE_TOKEN_ADDRESS, + type SquidFundingPlan, type SquidPublicClient, type SquidQuote, type SquidWalletClient, } from "./index.js" -const account = "0x1111111111111111111111111111111111111111" as const +const owner = "0x1111111111111111111111111111111111111111" as const const sourceToken = "0x2222222222222222222222222222222222222222" as const const destinationToken = "0x3333333333333333333333333333333333333333" as const const target = "0x4444444444444444444444444444444444444444" as const @@ -16,53 +18,133 @@ const spender = "0x5555555555555555555555555555555555555555" as const function quote(overrides: Partial = {}): SquidQuote { return { id: "planned", - source: { - chain: { chainId: 1, networkName: "Ethereum" }, - token: sourceToken, - symbol: "USDC", - decimals: 6, - native: false, - }, requirement: { id: "fund", - chainId: 10, + chainId: 314, token: destinationToken, amount: 10n, - recipient: account, + recipient: owner, }, sourceAmount: 10n, destinationAmount: 10n, target, + approvalSpender: spender, data: "0x01", value: 0n, - gasLimit: 1n, expiresAt: 2_000_000_000, ...overrides, } } +function plan( + quotes: readonly SquidQuote[] = [quote()], + overrides: Partial = {}, +): SquidFundingPlan { + return { + owner, + source: { chainId: 1, token: sourceToken, symbol: "USDC", decimals: 6 }, + quotes, + maxSourceAmount: quotes.reduce( + (total, item) => total + item.sourceAmount, + 0n, + ), + slippage: 1, + ...overrides, + } +} + +function provider( + options: { + mutateRoute?: (route: Record, call: number) => void + statuses?: string[] + } = {}, +) { + let routeCalls = 0 + let statusCalls = 0 + const fetch = (async (url, init) => { + if (String(url).includes("/status?")) { + const statuses = options.statuses ?? ["success"] + const status = statuses[Math.min(statusCalls++, statuses.length - 1)] + return new Response(JSON.stringify({ squidTransactionStatus: status })) + } + const request = JSON.parse(String(init?.body)) as { + fromAddress: string + toAddress: string + fromChain: string + fromToken: string + fromAmount: string + toChain: string + toToken: string + slippage: number + quoteOnly: boolean + } + routeCalls += 1 + const route: Record = { + quoteId: `fresh-${routeCalls}`, + params: { ...request }, + estimate: { toAmountMin: request.fromAmount }, + transactionRequest: { + target, + approvalSpender: spender, + data: `0x${routeCalls.toString(16).padStart(2, "0")}`, + value: + request.fromToken === NATIVE_TOKEN_ADDRESS ? request.fromAmount : "0", + expiry: "2000000000", + }, + } + options.mutateRoute?.(route, routeCalls) + return new Response(JSON.stringify({ route })) + }) as typeof globalThis.fetch + return { fetch, routeCalls: () => routeCalls, statusCalls: () => statusCalls } +} + function clients( options: { allowance?: bigint - fee?: bigint + feePerGas?: bigint totalFee?: bigint - sourceBalance?: bigint + sourceTokenBalance?: bigint + nativeBalance?: bigint destinationBalances?: bigint[] + pending?: boolean + nonceDrift?: boolean + walletDrift?: boolean reverted?: boolean } = {}, ) { - const calls = { send: 0, totalFee: 0, sent: [] as unknown[] } + const calls = { + send: 0, + totalFee: 0, + sent: [] as Array>, + prepared: [] as Array>, + } let allowance = options.allowance ?? 0n let destinationRead = 0 + let pendingReads = 0 + let walletChainReads = 0 const source = { getChainId: async () => 1, - getBalance: async () => options.sourceBalance ?? 1_000n, - getTransactionCount: async () => 7 + calls.send, - estimateGas: async () => 2n, - estimateFeesPerGas: async () => ({ - maxFeePerGas: options.fee ?? 3n, - maxPriorityFeePerGas: 1n, - }), + getBalance: async () => options.nativeBalance ?? 1_000n, + getTransactionCount: async (request: { blockTag: string }) => { + if (request.blockTag === "pending") pendingReads += 1 + if (options.pending && request.blockTag === "pending") return 8 + if ( + options.nonceDrift && + request.blockTag === "pending" && + pendingReads > 1 + ) + return 8 + return 7 + calls.send + }, + prepareTransactionRequest: async (request: Record) => { + calls.prepared.push(request) + return { + ...request, + gas: 2n, + maxFeePerGas: options.feePerGas ?? 3n, + maxPriorityFeePerGas: 1n, + } + }, estimateTotalFee: options.totalFee == null ? undefined @@ -71,14 +153,23 @@ function clients( return options.totalFee as bigint }, readContract: async (request: { functionName: string }) => - request.functionName === "allowance" ? allowance : 100n, + request.functionName === "allowance" + ? allowance + : (options.sourceTokenBalance ?? 1_000n), waitForTransactionReceipt: async () => ({ status: options.reverted ? "reverted" : "success", }), } as unknown as SquidPublicClient const destination = { ...source, - getChainId: async () => 10, + getChainId: async () => 314, + getBalance: async () => + (options.destinationBalances ?? [0n, 10n])[ + Math.min( + destinationRead++, + (options.destinationBalances ?? [0n, 10n]).length - 1, + ) + ] as bigint, readContract: async () => (options.destinationBalances ?? [0n, 10n])[ Math.min( @@ -88,63 +179,67 @@ function clients( ] as bigint, } as unknown as SquidPublicClient const wallet = { - account: undefined as Account | undefined, - getChainId: async () => 1, - getAddresses: async () => [account], - sendTransaction: async (request: unknown) => { + account: { address: owner, type: "json-rpc" } as Account, + getChainId: async () => { + walletChainReads += 1 + return options.walletDrift && walletChainReads > 1 ? 10 : 1 + }, + sendTransaction: async (request: Record) => { calls.send += 1 calls.sent.push(request) - const transaction = request as { data?: `0x${string}`; to?: string } - if (transaction.to === sourceToken && transaction.data != null) { + if (request.to === sourceToken && typeof request.data === "string") { const decoded = decodeFunctionData({ abi: erc20Abi, - data: transaction.data, + data: request.data as `0x${string}`, }) if (decoded.functionName === "approve") allowance = decoded.args[1] } return `0x${calls.send.toString().padStart(64, "a")}` }, } as unknown as SquidWalletClient - return { - source, - destination, - wallet, - calls, - } + return { source, destination, wallet, calls } } -function input(quotes: readonly SquidQuote[] = [quote()]) { +function input( + fundingPlan = plan(), + overrides: Partial[0]> = {}, +) { return { - account, - source: quotes[0]?.source as SquidQuote["source"], - quotes, - maxSourceAmount: 10n, + plan: fundingPlan, maxNativeFee: 20n, trustedTarget: target, trustedSpender: spender, feeMode: "standard" as const, maxPollAttempts: 2, pollIntervalMs: 1, + ...overrides, } } -function dependencies(mocked: ReturnType) { +function dependencies(mocked: ReturnType, squid = provider()) { return { publicClient: mocked.source, walletClient: mocked.wallet, - destinationClient: () => mocked.destination, - refreshQuote: async (planned: SquidQuote) => ({ ...planned, id: "fresh" }), - status: async () => "success" as const, - now: () => 0, + destinationClient: mocked.destination, + squid: { + integratorId: "test", + fetch: squid.fetch, + now: () => 0, + }, sleep: async () => {}, } } -describe("stateless guarded Squid execution", () => { - it("sends exact allowance then native-value route and returns CLI-sized results", async () => { +describe("guarded Squid execution", () => { + it("sets an exact allowance, uses RPC-prepared requests, and returns hashes", async () => { const mocked = clients() - const result = await executeSquidFunding(input(), dependencies(mocked)) + const squid = provider() + const result = await executeSquidFunding( + input(), + dependencies(mocked, squid), + ) expect(mocked.calls.send).toBe(2) + expect(mocked.calls.prepared).toHaveLength(2) expect(result).toEqual({ sourceAmount: 10n, nativeFee: 12n, @@ -159,334 +254,391 @@ describe("stateless guarded Squid execution", () => { expect.objectContaining({ to: sourceToken, gas: 2n, maxFeePerGas: 3n }), ) expect(mocked.calls.sent[1]).toEqual( - expect.objectContaining({ to: target, value: 0n, gas: 2n }), - ) - }) - - it("estimates without quoted gas and uses a larger estimate", async () => { - const mocked = clients({ allowance: 10n }) - let estimateRequest: unknown - mocked.source.estimateGas = async (request) => { - estimateRequest = request - return 5n - } - await executeSquidFunding(input(), dependencies(mocked)) - expect(estimateRequest).toEqual( - expect.objectContaining({ - account, - to: target, - data: "0x01", - value: 0n, - nonce: 7, - }), + expect.objectContaining({ to: target, data: "0x02", value: 0n }), ) - expect(estimateRequest).not.toHaveProperty("gas") - expect(mocked.calls.sent[0]).toEqual(expect.objectContaining({ gas: 5n })) }) - it("resets an overbroad allowance before setting the exact route amount", async () => { - const mocked = clients({ allowance: 20n }) + it("resets an overbroad allowance before setting the exact amount", async () => { + const mocked = clients({ allowance: 100n }) await executeSquidFunding(input(), dependencies(mocked)) expect(mocked.calls.send).toBe(3) - const approvals = mocked.calls.sent.slice(0, 2).map((item) => + const approvals = mocked.calls.sent.slice(0, 2).map((request) => decodeFunctionData({ abi: erc20Abi, - data: (item as { data: `0x${string}` }).data, + data: request.data as `0x${string}`, }), ) - expect(approvals.map((approval) => approval.args[1])).toEqual([0n, 10n]) + expect(approvals.map(({ args }) => args[1])).toEqual([0n, 10n]) }) - it("rejects changed route target, spender, and ERC-20 native value before sending", async () => { - for (const changed of [ - { target: spender }, - { approvalSpender: target }, - { value: 1n }, - ]) { + it("rejects refreshed trust-boundary changes before a route broadcast", async () => { + const cases: Array<{ + name: string + planned?: Partial + mutate: (route: Record) => void + }> = [ + { + name: "target", + mutate: (route) => { + const transaction = route.transactionRequest as Record< + string, + unknown + > + transaction.target = destinationToken + }, + }, + { + name: "spender", + mutate: (route) => { + const transaction = route.transactionRequest as Record< + string, + unknown + > + transaction.approvalSpender = destinationToken + }, + }, + { + name: "missing spender", + planned: { approvalSpender: spender }, + mutate: (route) => { + const transaction = route.transactionRequest as Record< + string, + unknown + > + delete transaction.approvalSpender + }, + }, + { + name: "ERC-20 value", + mutate: (route) => { + const transaction = route.transactionRequest as Record< + string, + unknown + > + transaction.value = "1" + }, + }, + ] + for (const item of cases) { const mocked = clients({ allowance: 10n }) - const deps = dependencies(mocked) - deps.refreshQuote = async (planned) => ({ ...planned, ...changed }) - await expect(executeSquidFunding(input(), deps)).rejects.toThrow( - "trust checks", - ) + const squid = provider({ mutateRoute: item.mutate }) + await expect( + executeSquidFunding( + input(plan([quote(item.planned)])), + dependencies(mocked, squid), + ), + item.name, + ).rejects.toThrow("trust checks") expect(mocked.calls.send).toBe(0) } - }) - - it("rejects invalid status configuration and a disappeared planned spender before sending", async () => { - const invalidStatus = clients() - await expect( - executeSquidFunding(input(), { - ...dependencies(invalidStatus), - status: "nope" as never, - }), - ).rejects.toThrow("status callback") - expect(invalidStatus.calls.send).toBe(0) - const blankBaseUrl = clients() - blankBaseUrl.source.getChainId = async () => { - throw new Error("RPC should not be called") - } - await expect( - executeSquidFunding(input(), { - ...dependencies(blankBaseUrl), - status: undefined, - squidStatusOptions: { integratorId: "test", baseUrl: " " }, + const expiring = clients() + const expiringDependencies = dependencies( + expiring, + provider({ + mutateRoute: (route) => { + const transaction = route.transactionRequest as Record< + string, + unknown + > + transaction.expiry = "1" + }, }), - ).rejects.toThrow("status options") - expect(blankBaseUrl.calls.send).toBe(0) - - const missingSpender = clients({ allowance: 10n }) - const planned = quote({ approvalSpender: spender }) - const deps = dependencies(missingSpender) - deps.refreshQuote = async (route) => ({ - ...route, - approvalSpender: undefined, - }) - await expect(executeSquidFunding(input([planned]), deps)).rejects.toThrow( - "trust checks", ) - expect(missingSpender.calls.send).toBe(0) + expiringDependencies.squid.now = () => + expiring.calls.send === 0 ? 0 : 2_000 + await expect( + executeSquidFunding(input(), expiringDependencies), + ).rejects.toThrow("expired route") + expect(expiring.calls.send).toBe(1) }) - it("enforces source and total native-fee caps, including OP Stack total fees", async () => { - const overSource = clients() + it("enforces source, native balance, and cumulative fee caps", async () => { + const overCap = plan([quote({ sourceAmount: 11n })], { + maxSourceAmount: 10n, + }) await expect( - executeSquidFunding( - { ...input(), maxSourceAmount: 9n }, - dependencies(overSource), - ), + executeSquidFunding(input(overCap), dependencies(clients())), ).rejects.toThrow("source-token cap") - const overFee = clients() + + const cases = [ + { + mocked: clients({ sourceTokenBalance: 9n, allowance: 10n }), + options: { sourceBalanceFloor: 0n }, + message: "Source-token balance", + }, + { + mocked: clients({ nativeBalance: 5n, allowance: 10n }), + options: { nativeBalanceFloor: 0n }, + message: "Native balance", + }, + { + mocked: clients({ allowance: 10n }), + options: { maxNativeFee: 5n }, + message: "total-native-fee cap", + }, + ] + for (const item of cases) + await expect( + executeSquidFunding( + input(plan(), item.options), + dependencies(item.mocked), + ), + ).rejects.toThrow(item.message) + + const twoLegs = plan( + [quote(), quote({ requirement: { ...quote().requirement, id: "two" } })], + { + source: { + chainId: 1, + token: NATIVE_TOKEN_ADDRESS, + symbol: "ETH", + decimals: 18, + }, + }, + ) + const mocked = clients({ destinationBalances: [0n, 10n, 0n, 10n] }) await expect( executeSquidFunding( - { ...input(), maxNativeFee: 5n }, - dependencies(overFee), + input(twoLegs, { maxNativeFee: 11n }), + dependencies(mocked), ), ).rejects.toThrow("total-native-fee cap") - const op = clients({ totalFee: 4n }) - await executeSquidFunding( - { ...input(), feeMode: "op-stack", opStackFeeBuffer: (fee) => fee + 1n }, - dependencies(op), - ) - expect(op.calls.totalFee).toBe(2) + expect(mocked.calls.send).toBe(1) + + const incompleteFee = clients({ allowance: 10n, feePerGas: 0n }) + await expect( + executeSquidFunding(input(), dependencies(incompleteFee)), + ).rejects.toThrow("Complete execution fee") + }) + + it("uses complete OP Stack fees and applies the caller's buffer", async () => { + const noEstimator = clients({ allowance: 10n }) await expect( executeSquidFunding( - { ...input(), feeMode: "op-stack" }, - dependencies(clients()), + input(plan(), { + feeMode: "op-stack", + opStackFeeBuffer: (fee) => fee, + }), + dependencies(noEstimator), ), - ).rejects.toThrow("OP Stack total-fee") + ).rejects.toThrow("total-fee accounting") + + const buffered = clients({ allowance: 10n, totalFee: 9n }) + const result = await executeSquidFunding( + input(plan(), { + feeMode: "op-stack", + maxNativeFee: 10n, + opStackFeeBuffer: (fee) => fee + 1n, + }), + dependencies(buffered), + ) + expect(result.nativeFee).toBe(10n) + expect(buffered.calls.totalFee).toBe(1) + + const shrinking = clients({ allowance: 10n, totalFee: 9n }) await expect( executeSquidFunding( - { - ...input(), + input(plan(), { feeMode: "op-stack", opStackFeeBuffer: (fee) => fee - 1n, - }, - dependencies(clients({ totalFee: 4n })), + }), + dependencies(shrinking), ), ).rejects.toThrow("must not reduce") - }) - it("stops a second route at the cumulative native-fee cap", async () => { - const first = quote() - const second = quote({ - requirement: { ...first.requirement, id: "second" }, - }) - const mocked = clients({ - allowance: 10n, - destinationBalances: [0n, 10n, 10n, 20n], - }) - await expect( - executeSquidFunding( - { - ...input([first, second]), - maxSourceAmount: 20n, - maxNativeFee: 10n, + const twoLegs = plan( + [quote(), quote({ requirement: { ...quote().requirement, id: "two" } })], + { + source: { + chainId: 1, + token: NATIVE_TOKEN_ADDRESS, + symbol: "ETH", + decimals: 18, }, - dependencies(mocked), - ), - ).rejects.toThrow("total-native-fee cap") - expect(mocked.calls.send).toBe(1) - }) - - it("stops a second OP Stack route at the cumulative total-fee cap", async () => { - const first = quote() - const second = quote({ - requirement: { ...first.requirement, id: "second" }, - }) - const mocked = clients({ - allowance: 10n, - totalFee: 4n, - destinationBalances: [0n, 10n, 10n, 20n], + }, + ) + const cumulative = clients({ + totalFee: 6n, + destinationBalances: [0n, 10n, 0n, 10n], }) await expect( executeSquidFunding( - { - ...input([first, second]), - maxSourceAmount: 20n, - maxNativeFee: 7n, + input(twoLegs, { feeMode: "op-stack", + maxNativeFee: 11n, opStackFeeBuffer: (fee) => fee, - }, - dependencies(mocked), + }), + dependencies(cumulative), ), ).rejects.toThrow("total-native-fee cap") - expect(mocked.calls.send).toBe(1) + expect(cumulative.calls.send).toBe(1) }) - it("keeps Filecoin/native source reserve for all unsent routes", async () => { - const nativeSource = { - chain: { chainId: 314, networkName: "Filecoin" }, - token: "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" as const, - symbol: "FIL", - decimals: 18, - native: true, - } - const nativeQuote = quote({ - source: nativeSource, - value: 10n, - target, - requirement: { ...quote().requirement, id: "fil" }, - }) - const mocked = clients({ sourceBalance: 15n }) - const deps = dependencies(mocked) - ;(mocked.source.getChainId as () => Promise) = async () => 314 - ;(mocked.wallet.getChainId as () => Promise) = async () => 314 - await expect( - executeSquidFunding( - { - ...input([nativeQuote]), - source: nativeSource, - maxSourceAmount: 10n, - maxNativeFee: 20n, - }, - deps, - ), - ).rejects.toThrow("Native balance") - expect(mocked.calls.send).toBe(0) - }) - - it("executes native value and verifies a native destination balance", async () => { - const nativeSource = { - chain: { chainId: 1, networkName: "Ethereum" }, - token: "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" as const, - symbol: "ETH", - decimals: 18, - native: true, - } - const nativeQuote = quote({ - source: nativeSource, - value: 10n, - requirement: { - ...quote().requirement, - token: nativeSource.token, + it("fails closed on pending nonces, nonce drift, wallet drift, and reverts", async () => { + const cases = [ + { + mocked: clients({ allowance: 10n, pending: true }), + message: "pending", }, - }) - const mocked = clients() - let reads = 0 - mocked.destination.getBalance = async () => (reads++ === 0 ? 0n : 10n) - const result = await executeSquidFunding( - { ...input([nativeQuote]), source: nativeSource }, - dependencies(mocked), - ) - expect(mocked.calls.send).toBe(1) - expect(mocked.calls.sent[0]).toEqual( - expect.objectContaining({ value: 10n }), - ) - expect(result.routes).toHaveLength(1) + { + mocked: clients({ allowance: 10n, nonceDrift: true }), + message: "nonce changed", + }, + { + mocked: clients({ allowance: 10n, walletDrift: true }), + message: "Wallet chain", + }, + { + mocked: clients({ allowance: 10n, reverted: true }), + message: "reverted", + }, + ] + for (const item of cases) + await expect( + executeSquidFunding(input(), dependencies(item.mocked)), + ).rejects.toThrow(item.message) }) - it("uses refreshed calldata and rechecks expiry after async preflight", async () => { - const calldata = clients({ allowance: 10n }) - const calldataDeps = dependencies(calldata) - calldataDeps.refreshQuote = async (planned) => ({ - ...planned, - data: "0x02", - }) - await executeSquidFunding(input(), calldataDeps) - expect(calldata.calls.sent[0]).toEqual( - expect.objectContaining({ data: "0x02" }), - ) + it("requires an account-bound wallet and matching source and destination chains", async () => { + const wrongAccount = clients({ allowance: 10n }) + wrongAccount.wallet.account = { + address: sourceToken, + type: "json-rpc", + } as Account + await expect( + executeSquidFunding(input(), dependencies(wrongAccount)), + ).rejects.toThrow("does not control") - const expired = clients({ allowance: 10n }) - let clock = 0 - const estimate = expired.source.estimateGas.bind(expired.source) - expired.source.estimateGas = async (request) => { - const result = await estimate(request) - clock = quote().expiresAt * 1_000 - return result - } - const expiredDeps = dependencies(expired) - expiredDeps.now = () => clock - await expect(executeSquidFunding(input(), expiredDeps)).rejects.toThrow( - "trust checks", - ) - expect(expired.calls.send).toBe(0) + const wrongSource = clients({ allowance: 10n }) + wrongSource.source.getChainId = async () => 10 + await expect( + executeSquidFunding(input(), dependencies(wrongSource)), + ).rejects.toThrow("Source RPC chain") + + const wrongDestination = clients({ allowance: 10n }) + wrongDestination.destination.getChainId = async () => 10 + await expect( + executeSquidFunding(input(), dependencies(wrongDestination)), + ).rejects.toThrow("Destination RPC chain") }) - it("fails closed for provider failure, pending nonce, and wallet drift", async () => { - const failed = clients({ allowance: 10n }) - const failedDeps = dependencies(failed) - failedDeps.status = async () => "failed" - await expect(executeSquidFunding(input(), failedDeps)).rejects.toThrow( - "Squid route failed", - ) - expect(failed.calls.send).toBe(1) + it("rejects ambiguous requirement and polling configuration", async () => { + const duplicate = plan([ + quote(), + quote({ requirement: { ...quote().requirement } }), + ]) + await expect( + executeSquidFunding(input(duplicate), dependencies(clients())), + ).rejects.toThrow("IDs must be unique") - const pending = clients({ allowance: 10n }) - pending.source.getTransactionCount = async ({ blockTag }) => - blockTag === "latest" ? 7 : 8 + const mixedDestinations = plan([ + quote(), + quote({ + requirement: { ...quote().requirement, id: "two", chainId: 10 }, + }), + ]) await expect( - executeSquidFunding(input(), dependencies(pending)), - ).rejects.toThrow("pending transactions") - expect(pending.calls.send).toBe(0) + executeSquidFunding(input(mixedDestinations), dependencies(clients())), + ).rejects.toThrow("one destination chain") - const drift = clients({ allowance: 10n }) - let walletReads = 0 - drift.wallet.getChainId = async () => (walletReads++ === 0 ? 1 : 10) await expect( - executeSquidFunding(input(), dependencies(drift)), - ).rejects.toThrow("Wallet chain") - expect(drift.calls.send).toBe(0) + executeSquidFunding( + input(plan(), { maxPollAttempts: 0 }), + dependencies(clients()), + ), + ).rejects.toThrow("Execution limits") }) - it("surfaces second-leg failure after first-leg success", async () => { - const first = quote() - const second = quote({ - requirement: { ...first.requirement, id: "second" }, - }) - const mocked = clients({ - allowance: 10n, - destinationBalances: [0n, 10n, 10n, 20n], + it("requires source receipt, Squid success, and destination arrival", async () => { + const cases = [ + { + mocked: clients({ allowance: 10n }), + squid: provider({ statuses: ["failed"] }), + message: "route failed", + }, + { + mocked: clients({ allowance: 10n, destinationBalances: [0n, 9n, 9n] }), + squid: provider({ statuses: ["success"] }), + message: "poll limit", + }, + { + mocked: clients({ allowance: 10n, destinationBalances: [0n, 10n] }), + squid: provider({ statuses: ["pending", "success"] }), + message: undefined, + }, + ] + for (const item of cases) { + const promise = executeSquidFunding( + input(), + dependencies(item.mocked, item.squid), + ) + if (item.message == null) await expect(promise).resolves.toBeDefined() + else await expect(promise).rejects.toThrow(item.message) + } + + const twoLegs = plan( + [quote(), quote({ requirement: { ...quote().requirement, id: "two" } })], + { + source: { + chainId: 1, + token: NATIVE_TOKEN_ADDRESS, + symbol: "ETH", + decimals: 18, + }, + }, + ) + const secondFails = clients({ + destinationBalances: [0n, 10n, 0n, 0n], }) - const deps = dependencies(mocked) - let statuses = 0 - deps.status = async () => (statuses++ === 0 ? "success" : "failed") await expect( executeSquidFunding( - { ...input([first, second]), maxSourceAmount: 20n }, - deps, + input(twoLegs), + dependencies( + secondFails, + provider({ statuses: ["success", "failed"] }), + ), ), - ).rejects.toThrow("Squid route failed") - expect(mocked.calls.send).toBe(2) - expect(statuses).toBe(2) + ).rejects.toThrow("route failed") + expect(secondFails.calls.send).toBe(2) }) - it("requires source receipt, Squid status, and destination balance", async () => { - const reverted = clients({ reverted: true }) - await expect( - executeSquidFunding(input(), dependencies(reverted)), - ).rejects.toThrow("Transaction reverted") - const incomplete = clients({ - allowance: 10n, - destinationBalances: [0n, 0n], + it("supports Filecoin/native sources and native destination balances with floors", async () => { + const nativeQuote = quote({ + requirement: { + ...quote().requirement, + token: NATIVE_TOKEN_ADDRESS, + }, + value: 10n, }) + const nativePlan = plan([nativeQuote], { + source: { + chainId: 1, + token: NATIVE_TOKEN_ADDRESS, + symbol: "ETH", + decimals: 18, + }, + }) + const mocked = clients({ nativeBalance: 20n }) + const result = await executeSquidFunding( + input(nativePlan, { + sourceBalanceFloor: 4n, + nativeBalanceFloor: 3n, + }), + dependencies(mocked), + ) + expect(result.sourceAmount).toBe(10n) + expect(mocked.calls.send).toBe(1) + + const insufficient = clients({ nativeBalance: 19n }) await expect( executeSquidFunding( - { ...input(), maxPollAttempts: 1 }, - dependencies(incomplete), + input(nativePlan, { + sourceBalanceFloor: 4n, + nativeBalanceFloor: 3n, + }), + dependencies(insufficient), ), - ).rejects.toThrow("did not complete") + ).rejects.toThrow("source amount, fee, and floor") }) }) diff --git a/src/execution.ts b/src/execution.ts index 5c64293..8a56ddd 100644 --- a/src/execution.ts +++ b/src/execution.ts @@ -5,18 +5,18 @@ import { type Hash, type Hex, } from "viem" -import { fetchSquidStatus } from "./squid.js" -import type { - SquidClientOptions, - SquidExecutionResult, - SquidPublicClient, - SquidQuote, - SquidStatusReference, - SquidWalletClient, +import { fetchSquidStatus, quoteSquidRoute } from "./squid.js" +import { + NATIVE_TOKEN_ADDRESS, + type SquidClientOptions, + type SquidExecutionResult, + type SquidFundingPlan, + type SquidPublicClient, + type SquidQuote, + type SquidWalletClient, } from "./types.js" -import { NATIVE_TOKEN_ADDRESS } from "./types.js" -type Transaction = { to: Address; data: Hex; value: bigint; gas?: bigint } +type Transaction = { to: Address; data: Hex; value: bigint } const MAX_POLL_INTERVAL_MS = 2_147_483_647 function sameAddress(a: Address, b: Address) { @@ -31,31 +31,17 @@ function sleep(milliseconds: number) { return new Promise((resolve) => setTimeout(resolve, milliseconds)) } -function validStatusOptions(value: unknown): value is SquidClientOptions { - if (value == null || typeof value !== "object") return false - const options = value as Record - return ( - typeof options.integratorId === "string" && - options.integratorId.trim() !== "" && - (options.baseUrl === undefined || - (typeof options.baseUrl === "string" && options.baseUrl.trim() !== "")) && - (options.fetch === undefined || typeof options.fetch === "function") - ) -} - function assertQuote( planned: SquidQuote, refreshed: SquidQuote, + source: SquidFundingPlan["source"], target: Address, spender: Address, now: number, ) { if ( refreshed.requirement.id !== planned.requirement.id || - refreshed.source.chain.chainId !== planned.source.chain.chainId || - !sameAddress(refreshed.source.token, planned.source.token) || refreshed.sourceAmount !== planned.sourceAmount || - refreshed.source.native !== planned.source.native || refreshed.requirement.chainId !== planned.requirement.chainId || !sameAddress(refreshed.requirement.token, planned.requirement.token) || !sameAddress( @@ -63,9 +49,7 @@ function assertQuote( planned.requirement.recipient, ) || refreshed.destinationAmount < planned.requirement.amount || - refreshed.gasLimit <= 0n || refreshed.id.trim() === "" || - (refreshed.requestId != null && refreshed.requestId.trim() === "") || !sameAddress(refreshed.target, target) || (planned.approvalSpender != null && (refreshed.approvalSpender == null || @@ -75,8 +59,8 @@ function assertQuote( !/^0x(?:[0-9a-fA-F]{2})+$/.test(refreshed.data) || !Number.isSafeInteger(refreshed.expiresAt) || refreshed.expiresAt <= now || - (refreshed.source.native && refreshed.value !== refreshed.sourceAmount) || - (!refreshed.source.native && refreshed.value !== 0n) + (native(source.token) && refreshed.value !== refreshed.sourceAmount) || + (!native(source.token) && refreshed.value !== 0n) ) throw new Error("Refreshed Squid route failed execution trust checks") } @@ -103,46 +87,45 @@ async function prepare( feeMode: "standard" | "op-stack", buffer: ((totalFee: bigint) => bigint) | undefined, ) { - const base = { - to: transaction.to, - data: transaction.data, - value: transaction.value, + const request = await client.prepareTransactionRequest({ + account, + chain: undefined, + ...transaction, nonce, - } - const [estimatedGas, fees] = await Promise.all([ - client.estimateGas({ account, ...base }), - client.estimateFeesPerGas(), - ]) - const gas = - transaction.gas != null && transaction.gas > estimatedGas - ? transaction.gas - : estimatedGas - if (gas <= 0n) throw new Error("Complete execution fee is unavailable") - const feeFields = - fees.maxFeePerGas != null && - fees.maxPriorityFeePerGas != null && - fees.maxFeePerGas > 0n && - fees.maxPriorityFeePerGas >= 0n && - fees.maxPriorityFeePerGas <= fees.maxFeePerGas - ? { - maxFeePerGas: fees.maxFeePerGas, - maxPriorityFeePerGas: fees.maxPriorityFeePerGas, - } - : fees.maxFeePerGas == null && fees.gasPrice != null && fees.gasPrice > 0n - ? { gasPrice: fees.gasPrice } - : undefined - if (feeFields == null) + }) + const gas = request.gas + const maxFeePerGas = request.maxFeePerGas + const maxPriorityFeePerGas = request.maxPriorityFeePerGas + const gasPrice = request.gasPrice + const hasLegacyFee = gasPrice != null && gasPrice > 0n + const hasEip1559Fee = + maxFeePerGas != null && + maxPriorityFeePerGas != null && + maxFeePerGas > 0n && + maxPriorityFeePerGas >= 0n && + maxPriorityFeePerGas <= maxFeePerGas + if (gas == null || gas <= 0n || (!hasLegacyFee && !hasEip1559Fee)) throw new Error("Complete execution fee is unavailable") - const request = { ...base, gas, ...feeFields } - if (feeMode === "standard") { - const perGas = ( - "gasPrice" in feeFields ? feeFields.gasPrice : feeFields.maxFeePerGas - ) as bigint - return { fee: gas * perGas, request } - } + + if (feeMode === "standard") + return { + fee: + gas * (hasLegacyFee ? (gasPrice as bigint) : (maxFeePerGas as bigint)), + request, + } if (client.estimateTotalFee == null || buffer == null) throw new Error("OP Stack total-fee accounting and buffer are required") - const total = await client.estimateTotalFee({ account, ...request }) + const total = await client.estimateTotalFee({ + account, + to: transaction.to, + data: transaction.data, + value: transaction.value, + nonce, + gas, + ...(gasPrice == null ? {} : { gasPrice }), + ...(maxFeePerGas == null ? {} : { maxFeePerGas }), + ...(maxPriorityFeePerGas == null ? {} : { maxPriorityFeePerGas }), + }) const fee = buffer(total) if (fee < total) throw new Error("OP Stack fee buffer must not reduce the total fee") @@ -151,10 +134,7 @@ async function prepare( export async function executeSquidFunding( input: { - account: Address - source: SquidQuote["source"] - quotes: readonly SquidQuote[] - maxSourceAmount: bigint + plan: SquidFundingPlan maxNativeFee: bigint sourceBalanceFloor?: bigint nativeBalanceFloor?: bigint @@ -168,24 +148,19 @@ export async function executeSquidFunding( dependencies: { publicClient: SquidPublicClient walletClient: SquidWalletClient - destinationClient: (chainId: number) => SquidPublicClient - refreshQuote: (quote: SquidQuote) => Promise - status?: ( - status: SquidStatusReference, - transactionHash: Hash, - ) => Promise<"pending" | "success" | "failed"> - squidStatusOptions?: SquidClientOptions - now?: () => number + destinationClient: SquidPublicClient + squid: SquidClientOptions sleep?: (milliseconds: number) => Promise }, ): Promise { + const { plan } = input if ( - input.quotes.length === 0 || - input.maxSourceAmount <= 0n || + plan.quotes.length === 0 || + plan.maxSourceAmount <= 0n || input.maxNativeFee < 0n || (input.sourceBalanceFloor ?? 0n) < 0n || (input.nativeBalanceFloor ?? 0n) < 0n || - input.quotes.some( + plan.quotes.some( (quote) => quote.sourceAmount <= 0n || quote.requirement.amount <= 0n, ) || (input.feeMode !== "standard" && input.feeMode !== "op-stack") || @@ -197,61 +172,47 @@ export async function executeSquidFunding( ) throw new Error("Execution limits and at least one quote are required") if ( - new Set(input.quotes.map((quote) => quote.requirement.id)).size !== - input.quotes.length + new Set(plan.quotes.map((quote) => quote.requirement.id)).size !== + plan.quotes.length ) throw new Error("Execution requirement IDs must be unique") - if ( - native(input.source.token) !== input.source.native || - input.quotes.some( - (quote) => - !sameAddress(quote.source.token, input.source.token) || - quote.source.chain.chainId !== input.source.chain.chainId || - quote.source.native !== input.source.native || - native(quote.source.token) !== quote.source.native, - ) + const destinationChainIds = new Set( + plan.quotes.map((quote) => quote.requirement.chainId), ) - throw new Error( - "All execution quotes must use the supplied source identity", - ) - const sourceAmount = input.quotes.reduce( + if (destinationChainIds.size !== 1) + throw new Error("All requirements must use one destination chain") + + const sourceAmount = plan.quotes.reduce( (total, quote) => total + quote.sourceAmount, 0n, ) - if (sourceAmount > input.maxSourceAmount) + if (sourceAmount > plan.maxSourceAmount) throw new Error("Execution would exceed the source-token cap") - if (dependencies.status != null && typeof dependencies.status !== "function") - throw new Error("Squid status callback must be callable") - const status = - dependencies.status ?? - (validStatusOptions(dependencies.squidStatusOptions) - ? (reference: SquidStatusReference, transactionHash: Hash) => - fetchSquidStatus( - { status: reference, transactionHash }, - dependencies.squidStatusOptions as SquidClientOptions, - ) - : undefined) - if (status == null) - throw new Error("Squid status options or a status callback are required") - if ( - (await dependencies.publicClient.getChainId()) !== - input.source.chain.chainId - ) + if ((await dependencies.publicClient.getChainId()) !== plan.source.chainId) throw new Error("Source RPC chain does not match the Squid source chain") - if ( - (await dependencies.walletClient.getChainId()) !== - input.source.chain.chainId - ) + if ((await dependencies.walletClient.getChainId()) !== plan.source.chainId) throw new Error("Wallet chain does not match the Squid source chain") - const configuredAccount = dependencies.walletClient.account + if (!sameAddress(dependencies.walletClient.account.address, plan.owner)) + throw new Error("Wallet client does not control the requested account") + const destinationChainId = destinationChainIds.values().next().value if ( - configuredAccount != null - ? !sameAddress(configuredAccount.address, input.account) - : !(await dependencies.walletClient.getAddresses()).some((address) => - sameAddress(address, input.account), - ) + destinationChainId == null || + (await dependencies.destinationClient.getChainId()) !== destinationChainId ) - throw new Error("Wallet client does not control the requested account") + throw new Error("Destination RPC chain does not match the Squid route") + + const refresh = (quote: SquidQuote) => + quoteSquidRoute( + { + owner: plan.owner, + source: plan.source, + requirement: quote.requirement, + sourceAmount: quote.sourceAmount, + slippage: plan.slippage, + }, + dependencies.squid, + ) + const now = () => Math.floor((dependencies.squid.now ?? Date.now)() / 1000) let totalNativeFee = 0n const routes: Array<{ requirementId: string; transactionHash: Hash }> = [] const send = async ( @@ -261,11 +222,11 @@ export async function executeSquidFunding( ) => { const [latestNonce, pendingNonce] = await Promise.all([ dependencies.publicClient.getTransactionCount({ - address: input.account, + address: plan.owner, blockTag: "latest", }), dependencies.publicClient.getTransactionCount({ - address: input.account, + address: plan.owner, blockTag: "pending", }), ]) @@ -274,7 +235,7 @@ export async function executeSquidFunding( const prepared = await prepare( dependencies.publicClient, transaction, - input.account, + plan.owner, pendingNonce, input.feeMode, input.opStackFeeBuffer, @@ -282,12 +243,12 @@ export async function executeSquidFunding( if (totalNativeFee + prepared.fee > input.maxNativeFee) throw new Error("Execution would exceed the total-native-fee cap") const [nativeBalance, sourceBalance] = await Promise.all([ - dependencies.publicClient.getBalance({ address: input.account }), - input.source.native + dependencies.publicClient.getBalance({ address: plan.owner }), + native(plan.source.token) ? Promise.resolve(undefined) - : balance(dependencies.publicClient, input.source.token, input.account), + : balance(dependencies.publicClient, plan.source.token, plan.owner), ]) - if (input.source.native) { + if (native(plan.source.token)) { const floor = (input.sourceBalanceFloor ?? 0n) > (input.nativeBalanceFloor ?? 0n) ? (input.sourceBalanceFloor ?? 0n) @@ -307,20 +268,17 @@ export async function executeSquidFunding( } if ( (await dependencies.publicClient.getTransactionCount({ - address: input.account, + address: plan.owner, blockTag: "pending", })) !== pendingNonce ) throw new Error("Pending nonce changed before broadcast") - if ( - (await dependencies.walletClient.getChainId()) !== - input.source.chain.chainId - ) + if ((await dependencies.walletClient.getChainId()) !== plan.source.chainId) throw new Error("Wallet chain does not match the Squid source chain") validate?.() totalNativeFee += prepared.fee const transactionHash = (await dependencies.walletClient.sendTransaction({ - account: configuredAccount ?? input.account, + account: dependencies.walletClient.account, chain: undefined, ...prepared.request, } as never)) as Hash @@ -330,43 +288,40 @@ export async function executeSquidFunding( if (receipt.status !== "success") throw new Error("Transaction reverted") return transactionHash } - for (let index = 0; index < input.quotes.length; index += 1) { - const planned = input.quotes[index] as SquidQuote - const destinationClient = dependencies.destinationClient( - planned.requirement.chainId, - ) - if ((await destinationClient.getChainId()) !== planned.requirement.chainId) - throw new Error("Destination RPC chain does not match the Squid route") - let refreshed = await dependencies.refreshQuote(planned) - const now = () => Math.floor((dependencies.now ?? Date.now)() / 1000) + + for (let index = 0; index < plan.quotes.length; index += 1) { + const planned = plan.quotes[index] as SquidQuote + let refreshed = await refresh(planned) assertQuote( planned, refreshed, + plan.source, input.trustedTarget, input.trustedSpender, now(), ) - const remainingSource = input.quotes + const remainingSource = plan.quotes .slice(index) .reduce((total, quote) => total + quote.sourceAmount, 0n) const before = (await balance( - destinationClient, + dependencies.destinationClient, planned.requirement.token, planned.requirement.recipient, )) + planned.requirement.amount - if (!input.source.native) { + + if (!native(plan.source.token)) { let allowance = await dependencies.publicClient.readContract({ - address: input.source.token, + address: plan.source.token, abi: erc20Abi, functionName: "allowance", - args: [input.account, input.trustedSpender], + args: [plan.owner, input.trustedSpender], }) if (allowance !== refreshed.sourceAmount) { if (allowance > 0n) await send( { - to: input.source.token, + to: plan.source.token, data: encodeFunctionData({ abi: erc20Abi, functionName: "approve", @@ -378,7 +333,7 @@ export async function executeSquidFunding( ) await send( { - to: input.source.token, + to: plan.source.token, data: encodeFunctionData({ abi: erc20Abi, functionName: "approve", @@ -388,19 +343,20 @@ export async function executeSquidFunding( }, remainingSource, ) - refreshed = await dependencies.refreshQuote(planned) + refreshed = await refresh(planned) assertQuote( planned, refreshed, + plan.source, input.trustedTarget, input.trustedSpender, now(), ) allowance = await dependencies.publicClient.readContract({ - address: input.source.token, + address: plan.source.token, abi: erc20Abi, functionName: "allowance", - args: [input.account, input.trustedSpender], + args: [plan.owner, input.trustedSpender], }) if (allowance !== refreshed.sourceAmount) throw new Error( @@ -411,6 +367,7 @@ export async function executeSquidFunding( assertQuote( planned, refreshed, + plan.source, input.trustedTarget, input.trustedSpender, now(), @@ -420,32 +377,32 @@ export async function executeSquidFunding( to: refreshed.target, data: refreshed.data, value: refreshed.value, - gas: refreshed.gasLimit, }, remainingSource, () => assertQuote( planned, refreshed, + plan.source, input.trustedTarget, input.trustedSpender, now(), ), ) - const reference = { - quoteId: refreshed.id, - ...(refreshed.requestId == null - ? {} - : { requestId: refreshed.requestId }), - fromChainId: refreshed.source.chain.chainId, - toChainId: refreshed.requirement.chainId, - } let complete = false for (let attempt = 0; attempt < input.maxPollAttempts; attempt += 1) { const [routeStatus, after] = await Promise.all([ - status(reference, transactionHash), + fetchSquidStatus( + { + quoteId: refreshed.id, + transactionHash, + fromChainId: plan.source.chainId, + toChainId: refreshed.requirement.chainId, + }, + dependencies.squid, + ), balance( - destinationClient, + dependencies.destinationClient, planned.requirement.token, planned.requirement.recipient, ), diff --git a/src/index.test.ts b/src/index.test.ts index 7dc197a..e31f44c 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,781 +1,322 @@ import { describe, expect, it } from "vitest" -import { - fetchSquidCatalog, - parseSquidCatalog, - parseSquidStatus, - planSquidFunding, - quoteSquidRoute, - resolveSourceToken, -} from "./index.js" +import * as library from "./index.js" +import { NATIVE_TOKEN_ADDRESS, planSquidFunding } from "./index.js" const owner = "0x1111111111111111111111111111111111111111" as const -const sourceAddress = "0x2222222222222222222222222222222222222222" as const -const destination = "0x3333333333333333333333333333333333333333" as const +const sourceToken = "0x2222222222222222222222222222222222222222" as const +const destinationToken = "0x3333333333333333333333333333333333333333" as const const target = "0x4444444444444444444444444444444444444444" as const +const spender = "0x5555555555555555555555555555555555555555" as const -const chains = [ +type RouteRequest = { + fromAddress: string + toAddress: string + fromChain: string + fromToken: string + fromAmount: string + toChain: string + toToken: string + slippage: number + quoteOnly: boolean +} + +const tokens = [ { chainId: "1", - type: "evm", - networkName: "Ethereum", - nativeCurrency: { symbol: "ETH", decimals: 18 }, + address: sourceToken, + symbol: "USDC", + decimals: 6, + }, + { + chainId: "1", + address: NATIVE_TOKEN_ADDRESS, + symbol: "ETH", + decimals: 18, }, -] -const tokens = [ - { chainId: "1", address: sourceAddress, symbol: "USDC", decimals: 6 }, { chainId: "osmosis-1", address: "uosmo", symbol: "OSMO", decimals: 6 }, ] -function routeFetch( - output: (input: bigint) => bigint, - approvalSpender?: string, +function requirement(id = "fund", amount = 10n) { + return { + id, + chainId: 314, + token: destinationToken, + amount, + recipient: owner, + } +} + +function route( + request: RouteRequest, + output = BigInt(request.fromAmount), + mutate?: (value: Record) => void, ) { - let calls = 0 - const fetch = async (_url: string | URL | Request, init?: RequestInit) => { - calls += 1 - const request = JSON.parse(String(init?.body)) as { - fromAmount: string - slippage: number - } - const fromAmount = BigInt(request.fromAmount) - return new Response( - JSON.stringify({ - route: { - quoteId: `quote-${calls}`, - params: { - fromChain: "1", - fromToken: sourceAddress, - fromAmount: request.fromAmount, - fromAddress: owner, - toChain: "314", - toToken: destination, - toAddress: owner, - slippage: request.slippage, - quoteOnly: false, - }, - estimate: { toAmountMin: output(fromAmount).toString() }, - transactionRequest: { - target, - data: "0x01", - value: "0", - gasLimit: "1", - maxFeePerGas: "1", - expiry: "2000000000", - ...(approvalSpender == null ? {} : { approvalSpender }), - }, - }, - }), - { status: 200 }, - ) + const value: Record = { + route: { + quoteId: `quote-${request.fromAmount}`, + params: { ...request }, + estimate: { toAmountMin: output.toString() }, + transactionRequest: { + target, + approvalSpender: spender, + data: "0x01", + value: + request.fromToken === NATIVE_TOKEN_ADDRESS ? request.fromAmount : "0", + expiry: "2000000000", + }, + }, } - return { fetch: fetch as typeof globalThis.fetch, calls: () => calls } + mutate?.(value) + return new Response(JSON.stringify(value)) } -describe("Squid catalog and planner", () => { - it("accepts arbitrary tokens on every selected source chain", () => { - const chainIds = [314, 42161, 1, 8453, 10, 137, 43114, 56] - const catalog = parseSquidCatalog( - chainIds.map((chainId) => ({ - chainId: String(chainId), - type: "evm", - networkName: `chain-${chainId}`, - nativeCurrency: { symbol: "NATIVE", decimals: 18 }, - })), - chainIds.map((chainId, index) => ({ - chainId: String(chainId), - address: `0x${(index + 1).toString(16).padStart(40, "0")}`, - symbol: `T${index}`, - decimals: 6, - })), - ) - expect(catalog.chains.size).toBe(8) - for (const [index, chainId] of chainIds.entries()) - expect( - resolveSourceToken(catalog, chainId, `T${index}`).chain.chainId, - ).toBe(chainId) - }) +function api(options: { + tokens?: unknown + route?: (request: RouteRequest, call: number) => Response +}) { + const requests: Array<{ url: string; init?: RequestInit }> = [] + let routeCalls = 0 + const fetch = (async (url, init) => { + requests.push({ url: String(url), init }) + if (String(url).endsWith("/tokens")) + return new Response(JSON.stringify(options.tokens ?? { tokens })) + const request = JSON.parse(String(init?.body)) as RouteRequest + routeCalls += 1 + return options.route?.(request, routeCalls) ?? route(request) + }) as typeof globalThis.fetch + return { fetch, requests, routeCalls: () => routeCalls } +} - it("rejects ambiguous source symbols", () => { - const catalog = parseSquidCatalog(chains, [ - ...tokens, - { chainId: "1", address: target, symbol: "USDC", decimals: 6 }, +function plan( + mocked: ReturnType, + overrides: Partial[0]> = {}, +) { + return planSquidFunding( + { + owner, + sourceChainId: 1, + sourceToken: "USDC", + requirements: [requirement()], + maxSourceAmount: "1", + slippage: 1, + ...overrides, + }, + { + integratorId: "test", + baseUrl: "https://example.test/v2", + fetch: mocked.fetch, + now: () => 0, + }, + ) +} + +describe("Squid funding planning", () => { + it("keeps the runtime API to planning, execution, and the native sentinel", () => { + expect(Object.keys(library).sort()).toEqual([ + "NATIVE_TOKEN_ADDRESS", + "executeSquidFunding", + "planSquidFunding", ]) - expect(() => resolveSourceToken(catalog, 1, "USDC")).toThrow("ambiguous") }) - it("rejects malformed token decimals", () => { - expect(() => - parseSquidCatalog(chains, [ - { - chainId: "1", - address: "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", - symbol: "ETH", - decimals: -1, - }, - ]), - ).toThrow("invalid decimals") + it("fetches only tokens and resolves a symbol, address, or native token", async () => { + for (const selector of ["USDC", sourceToken, "native"]) { + const mocked = api({}) + const result = await plan(mocked, { sourceToken: selector }) + expect(result.source.token).toBe( + selector === "native" ? NATIVE_TOKEN_ADDRESS : sourceToken, + ) + expect(mocked.requests[0]?.url).toBe("https://example.test/v2/tokens") + expect( + new Headers(mocked.requests[0]?.init?.headers).get("x-integrator-id"), + ).toBe("test") + expect(mocked.requests.some(({ url }) => url.endsWith("/chains"))).toBe( + false, + ) + } }) - it("keeps native aliases on the eight selected source chains", () => { - const filecoin = [ + it("fails closed on malformed, duplicate, ambiguous, or missing source tokens", async () => { + const cases = [ { - chainId: "314", - type: "evm", - networkName: "Filecoin", - nativeCurrency: { symbol: "FIL", decimals: 18 }, + tokens: { tokens: [{ ...tokens[0], decimals: -1 }] }, + message: "invalid decimals", }, - ] - const catalog = parseSquidCatalog(filecoin, [ { - chainId: "314", - address: "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", - symbol: "FIL.native", - decimals: 18, + tokens: { tokens: [tokens[0], tokens[0]] }, + message: "duplicated", }, - ]) - expect(resolveSourceToken(catalog, 314, "native").symbol).toBe("FIL.native") - const filtered = parseSquidCatalog( - [ - { - chainId: "42220", - type: "evm", - networkName: "Celo", - nativeCurrency: { symbol: "CELO", decimals: 18 }, - }, - ], - [ - { - chainId: "42220", - address: "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", - symbol: "CELO", - decimals: 18, - }, - ], - ) - expect(() => resolveSourceToken(filtered, 42220, "native")).toThrow( - "does not support", - ) - }) - - it("fetches both current Squid catalogs with the integrator ID", async () => { - const requests: string[] = [] - const fetch = (async (url, init) => { - requests.push(String(url)) - expect(new Headers(init?.headers).get("x-integrator-id")).toBe("test") - return new Response( - JSON.stringify( - String(url).endsWith("/chains") ? { chains } : { tokens }, - ), - ) - }) as typeof globalThis.fetch - const catalog = await fetchSquidCatalog({ - integratorId: "test", - baseUrl: "https://example.test/v2", - fetch, - }) - expect(catalog.tokens).toHaveLength(1) - expect(requests).toEqual([ - "https://example.test/v2/chains", - "https://example.test/v2/tokens", - ]) - }) - - it("rejects a blank integrator ID before making provider calls", async () => { - const fetch = (() => { - throw new Error("provider should not be called") - }) as typeof globalThis.fetch - await expect( - fetchSquidCatalog({ integratorId: " ", fetch }), - ).rejects.toThrow("integrator ID") - }) - - it("normalizes documented Squid route status values", () => { - expect(parseSquidStatus({ squidTransactionStatus: "SUCCESS" })).toBe( - "success", - ) - expect(parseSquidStatus({ status: "ONGOING" })).toBe("pending") - expect(parseSquidStatus({ status: "REFUND" })).toBe("failed") - }) - - it("keeps an explicit provider approval spender for execution validation", async () => { - const source = resolveSourceToken( - parseSquidCatalog(chains, tokens), - 1, - "USDC", - ) - const result = await quoteSquidRoute( { - owner, - source, - requirement: { - id: "fund", - chainId: 314, - token: destination, - amount: 1n, - recipient: owner, + tokens: { + tokens: [tokens[0], { ...tokens[0], address: target }], }, - sourceAmount: 1n, - slippage: 1, + message: "ambiguous", }, - { - integratorId: "test", - fetch: routeFetch((amount) => amount, target).fetch, - now: () => 0, - }, - ) - expect(result.approvalSpender).toBe(target) + { tokens: { tokens: [] }, message: "not supported" }, + { tokens: { nope: [] }, message: "tokens must be an array" }, + ] + for (const item of cases) + await expect(plan(api({ tokens: item.tokens }))).rejects.toThrow( + item.message, + ) }) - it("rejects a multi-leg plan when an early route expires before return", async () => { - const source = resolveSourceToken( - parseSquidCatalog(chains, tokens), - 1, - "USDC", - ) - const original = routeFetch((amount) => amount).fetch - const fetch = (async (url, init) => { - const response = await original(url, init) - const body = (await response.json()) as { - route: { transactionRequest: { expiry: string } } - } - body.route.transactionRequest.expiry = "50" - return new Response(JSON.stringify(body)) - }) as typeof globalThis.fetch - let clock = 0 + it("validates the integrator ID, cap, requirements, and slippage", async () => { + const mocked = api({}) await expect( planSquidFunding( { owner, - source, - requirements: [ - { - id: "one", - chainId: 314, - token: destination, - amount: 1n, - recipient: owner, - }, - { - id: "two", - chainId: 314, - token: destination, - amount: 1n, - recipient: owner, - }, - ], - maxSourceAmount: 2n, - initialSourceAmount: 1n, + sourceChainId: 1, + sourceToken: "USDC", + requirements: [requirement()], + maxSourceAmount: "1", slippage: 1, }, - { - integratorId: "test", - fetch, - now: () => (clock++ < 2 ? 0 : 51_000), - }, + { integratorId: " ", fetch: mocked.fetch }, ), - ).rejects.toThrow("expired before planning completed") - }) + ).rejects.toThrow("integrator ID") + expect(mocked.requests).toHaveLength(0) - it("rejects malformed catalog wrappers", async () => { - const fetch = (async () => - new Response(JSON.stringify({ chains }))) as typeof globalThis.fetch - await expect( - fetchSquidCatalog({ integratorId: "test", fetch }), - ).rejects.toThrow("catalog response") + for (const overrides of [ + { maxSourceAmount: "0" }, + { maxSourceAmount: "not-an-amount" }, + { requirements: [] }, + { requirements: [requirement("fund", 0n)] }, + { slippage: 0 }, + { slippage: 100 }, + ]) + await expect(plan(api({}), overrides)).rejects.toThrow() }) - it("requotes a successful seed down to a tiny exact shortfall", async () => { - const source = resolveSourceToken( - parseSquidCatalog(chains, tokens), - 1, - "USDC", - ) - const mocked = routeFetch((input) => input * 10n) - const quotes = await planSquidFunding( - { - owner, - source, - requirements: [ - { - id: "tiny", - chainId: 314, - token: destination, - amount: 1n, - recipient: owner, - }, - ], - maxSourceAmount: 500_000n, - initialSourceAmount: 100_000n, - slippage: 1, - }, - { integratorId: "test", fetch: mocked.fetch, now: () => 0 }, - ) - expect(quotes[0]?.sourceAmount).toBe(1n) - expect(mocked.calls()).toBe(2) - }) + it("downscales a successful seed and shares one cap across all legs", async () => { + const mocked = api({}) + const result = await plan(mocked, { + requirements: [requirement("fil", 10n), requirement("usdfc", 5n)], + maxSourceAmount: "0.000015", + }) + expect(result.quotes.map(({ sourceAmount }) => sourceAmount)).toEqual([ + 10n, + 5n, + ]) + expect(result.maxSourceAmount).toBe(15n) + expect(mocked.routeCalls()).toBe(3) - it("keeps all legs under one shared source cap", async () => { - const source = resolveSourceToken( - parseSquidCatalog(chains, tokens), - 1, - "USDC", - ) - const mocked = routeFetch((input) => input) + const exhausted = api({}) await expect( - planSquidFunding( - { - owner, - source, - requirements: [ - { - id: "one", - chainId: 314, - token: destination, - amount: 60n, - recipient: owner, - }, - { - id: "two", - chainId: 314, - token: destination, - amount: 60n, - recipient: owner, - }, - ], - maxSourceAmount: 100n, - initialSourceAmount: 50n, - slippage: 1, - }, - { integratorId: "test", fetch: mocked.fetch, now: () => 0 }, - ), + plan(exhausted, { + requirements: [requirement("fil", 10n), requirement("usdfc", 6n)], + maxSourceAmount: "0.000015", + }), ).rejects.toThrow("source-token cap") - expect(mocked.calls()).toBe(3) + expect(exhausted.routeCalls()).toBe(3) }) - it("rejects a route that changes the requested destination token", async () => { - const source = resolveSourceToken( - parseSquidCatalog(chains, tokens), - 1, - "USDC", - ) - const mocked = routeFetch((input) => input) - const original = mocked.fetch - mocked.fetch = (async (url, init) => { - const response = await original(url, init) - const body = (await response.json()) as { - route: { params: Record } - } - body.route.params.toToken = sourceAddress - return new Response(JSON.stringify(body)) - }) as typeof globalThis.fetch - await expect( - planSquidFunding( - { - owner, - source, - requirements: [ - { - id: "one", - chainId: 314, - token: destination, - amount: 1n, - recipient: owner, - }, - ], - maxSourceAmount: 10n, - initialSourceAmount: 1n, - slippage: 1, - }, - { integratorId: "test", fetch: mocked.fetch, now: () => 0 }, - ), - ).rejects.toThrow("request identity mismatch") - }) + it("keeps route request identity and executable fields fail closed", async () => { + const identityChanges: Array<[string, unknown]> = [ + ["fromChain", "10"], + ["fromToken", target], + ["fromAmount", "11"], + ["fromAddress", target], + ["toChain", "10"], + ["toToken", target], + ["toAddress", target], + ["slippage", 2], + ["quoteOnly", true], + ] + for (const [key, changed] of identityChanges) { + const mocked = api({ + route: (request) => + route(request, 10n, (value) => { + const routeValue = value.route as { + params: Record + } + routeValue.params[key] = changed + }), + }) + await expect(plan(mocked)).rejects.toThrow("Invalid Squid route") + } - it("rejects malformed executable route fields", async () => { - const source = resolveSourceToken( - parseSquidCatalog(chains, tokens), - 1, - "USDC", - ) - for (const [mutate, message] of [ - [ - (body: { route: { quoteId: string } }) => { - body.route.quoteId = "" - }, - "missing route fields", - ], - [ - (body: { route: { params: { slippage: number } } }) => { - body.route.params.slippage = 2 - }, - "request identity mismatch", - ], - [ - (body: { route: { params: { quoteOnly: boolean } } }) => { - body.route.params.quoteOnly = true - }, - "request identity mismatch", - ], - [ - (body: { route: { transactionRequest: { data: string } } }) => { - body.route.transactionRequest.data = "0x0" - }, - "calldata", - ], - ] as const) { - const mocked = routeFetch((input) => input) - const original = mocked.fetch - mocked.fetch = (async (url, init) => { - const response = await original(url, init) - const body = (await response.json()) as { - route: Record - } - mutate(body as never) - return new Response(JSON.stringify(body)) - }) as typeof globalThis.fetch + const transactionChanges: Array<[string, unknown]> = [ + ["target", "bad"], + ["approvalSpender", "bad"], + ["data", "0x0"], + ["value", "-1"], + ["expiry", "0"], + ] + for (const [key, changed] of transactionChanges) await expect( - planSquidFunding( - { - owner, - source, - requirements: [ - { - id: "one", - chainId: 314, - token: destination, - amount: 1n, - recipient: owner, - }, - ], - maxSourceAmount: 1n, - initialSourceAmount: 1n, - slippage: 1, - }, - { integratorId: "test", fetch: mocked.fetch, now: () => 0 }, + plan( + api({ + route: (request) => + route(request, 10n, (value) => { + const routeValue = value.route as { + transactionRequest: Record + } + routeValue.transactionRequest[key] = changed + }), + }), ), - ).rejects.toThrow(message) - } + ).rejects.toThrow("Invalid Squid route") }) - it("uses the official inclusive slippage bounds and preserves request IDs", async () => { - const source = resolveSourceToken( - parseSquidCatalog(chains, tokens), - 1, - "USDC", - ) - const mocked = routeFetch((input) => input) - const fetch = (async (url, init) => { - const response = await mocked.fetch(url, init) - return new Response(await response.text(), { - headers: { "x-request-id": "header-id" }, + it("handles explicit provider minimums without disguising other failures", async () => { + const minimum = (body: unknown) => + new Response(typeof body === "string" ? body : JSON.stringify(body), { + status: 400, }) - }) as typeof globalThis.fetch - const quotes = await planSquidFunding( - { - owner, - source, - requirements: [ - { - id: "one", - chainId: 314, - token: destination, - amount: 1n, - recipient: owner, - }, - ], - maxSourceAmount: 1n, - initialSourceAmount: 1n, - slippage: 99.99, - }, - { integratorId: "test", fetch, now: () => 0 }, - ) - expect(quotes[0]?.requestId).toBe("header-id") - await expect( - planSquidFunding( - { - owner, - source, - requirements: [ - { - id: "one", - chainId: 314, - token: destination, - amount: 1n, - recipient: owner, - }, - ], - maxSourceAmount: 1n, - initialSourceAmount: 1n, - slippage: 0.009, - }, - { integratorId: "test", fetch, now: () => 0 }, - ), - ).rejects.toThrow("0.01") - }) + const retry = api({ + route: (request, call) => + call === 1 + ? minimum({ error: { message: "amount below minimum" } }) + : route(request), + }) + await expect(plan(retry, { maxSourceAmount: "1" })).resolves.toMatchObject({ + maxSourceAmount: 1_000_000n, + }) - it("only rewrites an explicit provider minimum after downscaling", async () => { - const source = resolveSourceToken( - parseSquidCatalog(chains, tokens), - 1, - "USDC", - ) - let calls = 0 - const fetch = (async (_url, init) => { - calls += 1 - if (calls === 2) - return new Response( - JSON.stringify({ message: "below minimum amount" }), - { - status: 400, - }, - ) - return routeFetch((input) => input * 10n).fetch("", init) - }) as typeof globalThis.fetch - await expect( - planSquidFunding( - { - owner, - source, - requirements: [ - { - id: "one", - chainId: 314, - token: destination, - amount: 1n, - recipient: owner, - }, - ], - maxSourceAmount: 100n, - initialSourceAmount: 10n, - slippage: 1, - }, - { integratorId: "test", fetch, now: () => 0 }, - ), - ).rejects.toThrow("provider minimum") - }) + const downscaled = api({ + route: (request, call) => + call === 1 ? route(request, 1_000_000n) : minimum("input too small"), + }) + await expect(plan(downscaled)).rejects.toThrow("provider minimum") - it("retries a first minimum at the remaining cap", async () => { - const source = resolveSourceToken( - parseSquidCatalog(chains, tokens), - 1, - "USDC", - ) - let calls = 0 - const routes = routeFetch((input) => input) - const fetch = (async (url, init) => { - calls += 1 - if (calls === 1) - return new Response( - JSON.stringify({ message: "below minimum amount" }), - { - status: 400, - }, - ) - return routes.fetch(url, init) - }) as typeof globalThis.fetch - const quotes = await planSquidFunding( - { - owner, - source, - requirements: [ - { - id: "one", - chainId: 314, - token: destination, - amount: 80n, - recipient: owner, - }, - ], - maxSourceAmount: 100n, - initialSourceAmount: 10n, - slippage: 1, - }, - { integratorId: "test", fetch, now: () => 0 }, - ) - expect(quotes[0]?.sourceAmount).toBe(80n) - expect(calls).toBe(3) + const transient = api({ + route: () => + new Response(JSON.stringify({ message: "temporarily unavailable" }), { + status: 503, + }), + }) + await expect(plan(transient)).rejects.toThrow("quote failed (503)") }) - it("recognizes nested and plain-text minimums without rewriting transient client failures", async () => { - const source = resolveSourceToken( - parseSquidCatalog(chains, tokens), - 1, - "USDC", - ) - for (const body of [ - JSON.stringify({ error: { message: "amount below minimum" } }), - "amount below minimum", - ]) { - let calls = 0 - const routes = routeFetch((input) => input) - const fetch = (async (url, init) => { - calls += 1 - if (calls === 1) return new Response(body, { status: 400 }) - return routes.fetch(url, init) - }) as typeof globalThis.fetch - await expect( - planSquidFunding( - { - owner, - source, - requirements: [ - { - id: "one", - chainId: 314, - token: destination, - amount: 1n, - recipient: owner, - }, - ], - maxSourceAmount: 10n, - initialSourceAmount: 1n, - slippage: 1, - }, - { integratorId: "test", fetch, now: () => 0 }, - ), - ).resolves.toHaveLength(1) - } - const transient = (async () => - new Response("upstream timeout", { - status: 422, - })) as typeof globalThis.fetch + it("rejects expired multi-leg plans and retains a feasible fourth quote", async () => { + const expired = api({}) + let nowCalls = 0 await expect( planSquidFunding( { owner, - source, - requirements: [ - { - id: "one", - chainId: 314, - token: destination, - amount: 1n, - recipient: owner, - }, - ], - maxSourceAmount: 10n, - initialSourceAmount: 1n, + sourceChainId: 1, + sourceToken: "USDC", + requirements: [requirement("a"), requirement("b")], + maxSourceAmount: "1", slippage: 1, }, - { integratorId: "test", fetch: transient, now: () => 0 }, - ), - ).rejects.toThrow("Squid quote failed (422)") - }) - - it("checks the remaining cap once before rejecting an extrapolated shortfall", async () => { - const source = resolveSourceToken( - parseSquidCatalog(chains, tokens), - 1, - "USDC", - ) - let calls = 0 - const fetch = (async (_url, init) => { - calls += 1 - const request = JSON.parse(String(init?.body)) as { fromAmount: string } - return routeFetch((input) => (input === 10n ? 1n : input)).fetch("", { - body: JSON.stringify({ ...request, slippage: 1 }), - }) - }) as typeof globalThis.fetch - const quotes = await planSquidFunding( - { - owner, - source, - requirements: [ - { - id: "one", - chainId: 314, - token: destination, - amount: 50n, - recipient: owner, - }, - ], - maxSourceAmount: 100n, - initialSourceAmount: 10n, - slippage: 1, - }, - { integratorId: "test", fetch, now: () => 0 }, - ) - expect(quotes[0]?.sourceAmount).toBe(50n) - expect(calls).toBe(3) - }) - - it("does not quote a later leg after the shared cap is exhausted", async () => { - const source = resolveSourceToken( - parseSquidCatalog(chains, tokens), - 1, - "USDC", - ) - const mocked = routeFetch((input) => input) - await expect( - planSquidFunding( { - owner, - source, - requirements: [ - { - id: "one", - chainId: 314, - token: destination, - amount: 10n, - recipient: owner, - }, - { - id: "two", - chainId: 314, - token: destination, - amount: 1n, - recipient: owner, - }, - ], - maxSourceAmount: 10n, - initialSourceAmount: 10n, - slippage: 1, + integratorId: "test", + fetch: expired.fetch, + now: () => (nowCalls++ < 4 ? 0 : 2_000_000_000_000), }, - { integratorId: "test", fetch: mocked.fetch, now: () => 0 }, ), - ).rejects.toThrow("source-token cap") - expect(mocked.calls()).toBe(1) - }) + ).rejects.toThrow("expired") - it("keeps a feasible fourth quote when fixed fees prevent convergence", async () => { - const source = resolveSourceToken( - parseSquidCatalog(chains, tokens), - 1, - "USDC", - ) - const inputs: bigint[] = [] - const outputs = new Map([ - [100n, 50n], - [200n, 150n], - [134n, 84n], - [160n, 110n], - ]) - const fetch = (async (_url, init) => { - const request = JSON.parse(String(init?.body)) as { fromAmount: string } - const input = BigInt(request.fromAmount) - inputs.push(input) - return routeFetch(() => outputs.get(input) ?? 0n).fetch("", init) - }) as typeof globalThis.fetch - const quotes = await planSquidFunding( - { - owner, - source, - requirements: [ - { - id: "one", - chainId: 314, - token: destination, - amount: 100n, - recipient: owner, - }, - ], - maxSourceAmount: 200n, - initialSourceAmount: 100n, - slippage: 1, - }, - { integratorId: "test", fetch, now: () => 0 }, - ) - expect(inputs).toEqual([100n, 200n, 134n, 160n]) - expect(quotes[0]?.sourceAmount).toBe(160n) + const outputs = [1_000_000n, 20n, 9n, 10n] + const converges = api({ + route: (request, call) => route(request, outputs[call - 1] as bigint), + }) + const result = await plan(converges) + expect(result.quotes[0]?.destinationAmount).toBe(10n) + expect(converges.routeCalls()).toBe(4) }) }) diff --git a/src/index.ts b/src/index.ts index b40ab35..4c67b95 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,23 +1,13 @@ -export { parseSquidCatalog, resolveSourceToken } from "./catalog.js" export { executeSquidFunding } from "./execution.js" export { planSquidFunding } from "./planner.js" -export { - fetchSquidCatalog, - fetchSquidStatus, - parseSquidStatus, - quoteSquidRoute, - SquidMinimumAmountError, -} from "./squid.js" export type { DestinationRequirement, SourceToken, - SquidCatalog, - SquidChain, SquidClientOptions, SquidExecutionResult, + SquidFundingPlan, SquidPublicClient, SquidQuote, - SquidStatusReference, SquidWalletClient, } from "./types.js" export { NATIVE_TOKEN_ADDRESS } from "./types.js" diff --git a/src/planner.ts b/src/planner.ts index 4a999b6..6b8ebf5 100644 --- a/src/planner.ts +++ b/src/planner.ts @@ -1,9 +1,14 @@ -import type { Address } from "viem" -import { quoteSquidRoute, SquidMinimumAmountError } from "./squid.js" +import { type Address, parseUnits } from "viem" +import { resolveSourceToken } from "./catalog.js" +import { + fetchSourceTokens, + quoteSquidRoute, + SquidMinimumAmountError, +} from "./squid.js" import type { DestinationRequirement, - SourceToken, SquidClientOptions, + SquidFundingPlan, SquidQuote, } from "./types.js" @@ -16,23 +21,39 @@ function ceilDiv(numerator: bigint, denominator: bigint): bigint { export async function planSquidFunding( input: { owner: Address - source: SourceToken + sourceChainId: number + sourceToken: string requirements: readonly DestinationRequirement[] - maxSourceAmount: bigint - initialSourceAmount?: bigint + maxSourceAmount: string slippage: number }, options: SquidClientOptions, -): Promise { - if (input.maxSourceAmount <= 0n || input.requirements.length === 0) - throw new Error( - "A positive source cap and destination requirement are required", - ) - const seed = - input.initialSourceAmount ?? - 5n * 10n ** BigInt(Math.max(0, input.source.decimals - 1)) +): Promise { + if ( + !Number.isSafeInteger(input.sourceChainId) || + input.sourceChainId <= 0 || + input.requirements.length === 0 || + input.requirements.some((requirement) => requirement.amount <= 0n) + ) + throw new Error("A source chain and destination requirement are required") + + const source = resolveSourceToken( + await fetchSourceTokens(input.sourceChainId, options), + input.sourceChainId, + input.sourceToken, + ) + let maxSourceAmount: bigint + try { + maxSourceAmount = parseUnits(input.maxSourceAmount, source.decimals) + } catch { + throw new Error("The source-token cap must be a decimal amount") + } + if (maxSourceAmount <= 0n) + throw new Error("The source-token cap must be positive") + + const seed = 5n * 10n ** BigInt(Math.max(0, source.decimals - 1)) const quotes: SquidQuote[] = [] - let remaining = input.maxSourceAmount + let remaining = maxSourceAmount for (const requirement of input.requirements) { if (remaining <= 0n) throw new Error("Acquisition would exceed the source-token cap") @@ -44,7 +65,7 @@ export async function planSquidFunding( quote = await quoteSquidRoute( { owner: input.owner, - source: input.source, + source, requirement, sourceAmount, slippage: input.slippage, @@ -111,5 +132,11 @@ export async function planSquidFunding( const now = Math.floor((options.now ?? Date.now)() / 1000) if (quotes.some((quote) => quote.expiresAt <= now)) throw new Error("A planned Squid route expired before planning completed") - return quotes + return { + owner: input.owner, + source, + quotes, + maxSourceAmount, + slippage: input.slippage, + } } diff --git a/src/squid.ts b/src/squid.ts index f32a01d..d4f8b66 100644 --- a/src/squid.ts +++ b/src/squid.ts @@ -1,11 +1,10 @@ import type { Address, Hash, Hex } from "viem" -import { parseSquidCatalog } from "./catalog.js" +import { parseSourceTokens } from "./catalog.js" import type { DestinationRequirement, SourceToken, SquidClientOptions, SquidQuote, - SquidStatusReference, } from "./types.js" const DEFAULT_BASE_URL = "https://v2.api.squidrouter.com/v2" @@ -19,9 +18,7 @@ type RouteWire = { target?: string data?: string value?: string - gasLimit?: string expiry?: string - requestId?: string approvalSpender?: string } } @@ -29,24 +26,13 @@ type RouteWire = { export class SquidMinimumAmountError extends Error {} -export function parseSquidStatus( - value: unknown, -): "pending" | "success" | "failed" { - const status = - value != null && typeof value === "object" - ? ((value as Record).squidTransactionStatus ?? - (value as Record).status) - : undefined - if (typeof status !== "string") - throw new Error("Invalid Squid status response") - if (status.toLowerCase() === "success") return "success" - if ( - ["failed", "refund", "needs_gas", "partial_success"].includes( - status.toLowerCase(), - ) - ) - return "failed" - return "pending" +function client(options: SquidClientOptions) { + if (options.integratorId.trim() === "") + throw new Error("Squid integrator ID is required") + return { + fetch: options.fetch ?? globalThis.fetch, + baseUrl: options.baseUrl ?? DEFAULT_BASE_URL, + } } function address(value: unknown, label: string): Address { @@ -92,50 +78,19 @@ function isMinimumMessage(message: string | undefined): boolean { ) } -function client(options: SquidClientOptions): { - fetch: typeof globalThis.fetch - baseUrl: string -} { - if (options.integratorId.trim() === "") - throw new Error("Squid integrator ID is required") - return { - fetch: options.fetch ?? globalThis.fetch, - baseUrl: options.baseUrl ?? DEFAULT_BASE_URL, - } -} - -/** Fetch the current Squid EVM chains and tokens with the supplied integrator ID. */ -export async function fetchSquidCatalog(options: SquidClientOptions) { +export async function fetchSourceTokens( + sourceChainId: number, + options: SquidClientOptions, +) { const configured = client(options) - const [chains, tokens] = await Promise.all( - ["chains", "tokens"].map(async (resource) => { - const response = await configured.fetch( - `${configured.baseUrl}/${resource}`, - { - headers: { "x-integrator-id": options.integratorId }, - }, - ) - if (!response.ok) - throw new Error(`Squid ${resource} request failed (${response.status})`) - return response.json() - }), - ) - if ( - chains == null || - typeof chains !== "object" || - !Array.isArray((chains as { chains?: unknown }).chains) || - tokens == null || - typeof tokens !== "object" || - !Array.isArray((tokens as { tokens?: unknown }).tokens) - ) - throw new Error("Invalid Squid catalog response") - return parseSquidCatalog( - (chains as { chains: unknown[] }).chains, - (tokens as { tokens: unknown[] }).tokens, - ) + const response = await configured.fetch(`${configured.baseUrl}/tokens`, { + headers: { "x-integrator-id": options.integratorId }, + }) + if (!response.ok) + throw new Error(`Squid tokens request failed (${response.status})`) + return parseSourceTokens(await response.json(), sourceChainId) } -/** Quote one fixed source amount. This validates route identity but leaves target/spender trust policy to execution. */ export async function quoteSquidRoute( input: { owner: Address @@ -154,6 +109,7 @@ export async function quoteSquidRoute( input.slippage > 99.99 ) throw new Error("Squid slippage must be between 0.01 and 99.99") + const configured = client(options) const response = await configured.fetch(`${configured.baseUrl}/route`, { method: "POST", @@ -164,7 +120,7 @@ export async function quoteSquidRoute( body: JSON.stringify({ fromAddress: input.owner, toAddress: input.requirement.recipient, - fromChain: String(input.source.chain.chainId), + fromChain: String(input.source.chainId), fromToken: input.source.token, fromAmount: input.sourceAmount.toString(), toChain: String(input.requirement.chainId), @@ -195,6 +151,7 @@ export async function quoteSquidRoute( `Squid quote failed (${response.status})${message == null ? "" : `: ${message}`}`, ) } + const route = ((await response.json()) as RouteWire).route const transaction = route?.transactionRequest const params = route?.params @@ -206,13 +163,7 @@ export async function quoteSquidRoute( ) throw new Error("Invalid Squid route: missing route fields") if ( - transaction.requestId != null && - (typeof transaction.requestId !== "string" || - transaction.requestId.trim() === "") - ) - throw new Error("Invalid Squid route: request ID") - if ( - params.fromChain !== String(input.source.chain.chainId) || + params.fromChain !== String(input.source.chainId) || params.fromAmount !== input.sourceAmount.toString() || params.toChain !== String(input.requirement.chainId) || params.slippage !== input.slippage || @@ -234,20 +185,14 @@ export async function quoteSquidRoute( !/^0x(?:[0-9a-fA-F]{2})+$/.test(transaction.data) ) throw new Error("Invalid Squid route: calldata") + const approvalSpender = transaction.approvalSpender == null ? undefined : address(transaction.approvalSpender, "approval spender") return { id: route.quoteId, - ...(transaction.requestId == null - ? response.headers.get("x-request-id") == null || - response.headers.get("x-request-id")?.trim() === "" - ? {} - : { requestId: response.headers.get("x-request-id") as string } - : { requestId: transaction.requestId }), requirement: input.requirement, - source: input.source, sourceAmount: input.sourceAmount, destinationAmount: amount( route.estimate?.toAmountMin, @@ -257,33 +202,46 @@ export async function quoteSquidRoute( ...(approvalSpender == null ? {} : { approvalSpender }), data: transaction.data as Hex, value: amount(transaction.value ?? "0", "value"), - gasLimit: amount(transaction.gasLimit, "gas limit"), expiresAt, } } -/** Fetch and normalize the documented Squid v2 route-status response. */ export async function fetchSquidStatus( - input: { status: SquidStatusReference; transactionHash: Hash }, + input: { + quoteId: string + transactionHash: Hash + fromChainId: number + toChainId: number + }, options: SquidClientOptions, ): Promise<"pending" | "success" | "failed"> { const configured = client(options) const query = new URLSearchParams({ transactionId: input.transactionHash, - fromChainId: String(input.status.fromChainId), - toChainId: String(input.status.toChainId), - quoteId: input.status.quoteId, - ...(input.status.requestId == null - ? {} - : { requestId: input.status.requestId }), + fromChainId: String(input.fromChainId), + toChainId: String(input.toChainId), + quoteId: input.quoteId, }) const response = await configured.fetch( `${configured.baseUrl}/status?${query}`, - { - headers: { "x-integrator-id": options.integratorId }, - }, + { headers: { "x-integrator-id": options.integratorId } }, ) if (!response.ok) throw new Error(`Squid status request failed (${response.status})`) - return parseSquidStatus(await response.json()) + const value = await response.json() + const status = + value != null && typeof value === "object" + ? ((value as Record).squidTransactionStatus ?? + (value as Record).status) + : undefined + if (typeof status !== "string") + throw new Error("Invalid Squid status response") + if (status.toLowerCase() === "success") return "success" + if ( + ["failed", "refund", "needs_gas", "partial_success"].includes( + status.toLowerCase(), + ) + ) + return "failed" + return "pending" } diff --git a/src/types.ts b/src/types.ts index 4adec5b..0ca3bb9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,19 +1,20 @@ -import type { Address, Hash, Hex, PublicClient, WalletClient } from "viem" +import type { + Account, + Address, + Hash, + Hex, + PublicClient, + WalletClient, +} from "viem" export const NATIVE_TOKEN_ADDRESS = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" as Address -export interface SquidChain { - chainId: number - networkName: string -} - export interface SourceToken { - chain: SquidChain + chainId: number token: Address symbol: string decimals: number - native: boolean } export interface DestinationRequirement { @@ -26,23 +27,22 @@ export interface DestinationRequirement { export interface SquidQuote { id: string - requestId?: string requirement: DestinationRequirement - source: SourceToken sourceAmount: bigint destinationAmount: bigint target: Address - /** Present only when Squid explicitly supplies a separate approval spender. */ approvalSpender?: Address data: Hex value: bigint - gasLimit: bigint expiresAt: number } -export interface SquidCatalog { - chains: ReadonlyMap - tokens: readonly SourceToken[] +export interface SquidFundingPlan { + owner: Address + source: SourceToken + quotes: readonly SquidQuote[] + maxSourceAmount: bigint + slippage: number } export interface SquidClientOptions { @@ -52,13 +52,6 @@ export interface SquidClientOptions { now?: () => number } -export interface SquidStatusReference { - quoteId: string - requestId?: string - fromChainId: number - toChainId: number -} - export interface SquidExecutionResult { sourceAmount: bigint nativeFee: bigint @@ -67,11 +60,10 @@ export interface SquidExecutionResult { export type SquidPublicClient = Pick< PublicClient, - | "estimateFeesPerGas" - | "estimateGas" | "getBalance" | "getChainId" | "getTransactionCount" + | "prepareTransactionRequest" | "readContract" | "waitForTransactionReceipt" > & { @@ -90,5 +82,5 @@ export type SquidPublicClient = Pick< export type SquidWalletClient = Pick< WalletClient, - "account" | "getAddresses" | "getChainId" | "sendTransaction" -> + "getChainId" | "sendTransaction" +> & { account: Account } From ba3c5bba2071f75bcf6f8e8fc00b7c945f3a7756 Mon Sep 17 00:00:00 2001 From: Mikers Date: Thu, 30 Jul 2026 16:11:10 -1000 Subject: [PATCH 2/2] fix: validate and sign Squid plans safely --- src/execution.test.ts | 20 +++++++++++--------- src/execution.ts | 18 +++++++++--------- src/index.test.ts | 4 ++++ src/planner.ts | 10 ++++++++++ src/types.ts | 3 +-- 5 files changed, 35 insertions(+), 20 deletions(-) diff --git a/src/execution.test.ts b/src/execution.test.ts index 249e25e..fb21287 100644 --- a/src/execution.test.ts +++ b/src/execution.test.ts @@ -136,15 +136,6 @@ function clients( return 8 return 7 + calls.send }, - prepareTransactionRequest: async (request: Record) => { - calls.prepared.push(request) - return { - ...request, - gas: 2n, - maxFeePerGas: options.feePerGas ?? 3n, - maxPriorityFeePerGas: 1n, - } - }, estimateTotalFee: options.totalFee == null ? undefined @@ -184,6 +175,16 @@ function clients( walletChainReads += 1 return options.walletDrift && walletChainReads > 1 ? 10 : 1 }, + prepareTransactionRequest: async (request: Record) => { + calls.prepared.push(request) + return { + ...request, + account: owner, + gas: 2n, + maxFeePerGas: options.feePerGas ?? 3n, + maxPriorityFeePerGas: 1n, + } + }, sendTransaction: async (request: Record) => { calls.send += 1 calls.sent.push(request) @@ -256,6 +257,7 @@ describe("guarded Squid execution", () => { expect(mocked.calls.sent[1]).toEqual( expect.objectContaining({ to: target, data: "0x02", value: 0n }), ) + expect(mocked.calls.sent[1]?.account).toBe(mocked.wallet.account) }) it("resets an overbroad allowance before setting the exact amount", async () => { diff --git a/src/execution.ts b/src/execution.ts index 8a56ddd..c662b48 100644 --- a/src/execution.ts +++ b/src/execution.ts @@ -80,15 +80,15 @@ async function balance( } async function prepare( - client: SquidPublicClient, + publicClient: SquidPublicClient, + walletClient: SquidWalletClient, transaction: Transaction, - account: Address, nonce: number, feeMode: "standard" | "op-stack", buffer: ((totalFee: bigint) => bigint) | undefined, ) { - const request = await client.prepareTransactionRequest({ - account, + const request = await walletClient.prepareTransactionRequest({ + account: walletClient.account, chain: undefined, ...transaction, nonce, @@ -113,10 +113,10 @@ async function prepare( gas * (hasLegacyFee ? (gasPrice as bigint) : (maxFeePerGas as bigint)), request, } - if (client.estimateTotalFee == null || buffer == null) + if (publicClient.estimateTotalFee == null || buffer == null) throw new Error("OP Stack total-fee accounting and buffer are required") - const total = await client.estimateTotalFee({ - account, + const total = await publicClient.estimateTotalFee({ + account: walletClient.account.address, to: transaction.to, data: transaction.data, value: transaction.value, @@ -234,8 +234,8 @@ export async function executeSquidFunding( throw new Error("Source account has pending transactions") const prepared = await prepare( dependencies.publicClient, + dependencies.walletClient, transaction, - plan.owner, pendingNonce, input.feeMode, input.opStackFeeBuffer, @@ -278,9 +278,9 @@ export async function executeSquidFunding( validate?.() totalNativeFee += prepared.fee const transactionHash = (await dependencies.walletClient.sendTransaction({ + ...prepared.request, account: dependencies.walletClient.account, chain: undefined, - ...prepared.request, } as never)) as Hash const receipt = await dependencies.publicClient.waitForTransactionReceipt({ hash: transactionHash, diff --git a/src/index.test.ts b/src/index.test.ts index e31f44c..81f320d 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -183,6 +183,10 @@ describe("Squid funding planning", () => { { maxSourceAmount: "not-an-amount" }, { requirements: [] }, { requirements: [requirement("fund", 0n)] }, + { requirements: [requirement(), requirement()] }, + { + requirements: [requirement(), { ...requirement("other"), chainId: 10 }], + }, { slippage: 0 }, { slippage: 100 }, ]) diff --git a/src/planner.ts b/src/planner.ts index 6b8ebf5..cda5203 100644 --- a/src/planner.ts +++ b/src/planner.ts @@ -36,6 +36,16 @@ export async function planSquidFunding( input.requirements.some((requirement) => requirement.amount <= 0n) ) throw new Error("A source chain and destination requirement are required") + if ( + new Set(input.requirements.map((requirement) => requirement.id)).size !== + input.requirements.length + ) + throw new Error("Requirement IDs must be unique") + if ( + new Set(input.requirements.map((requirement) => requirement.chainId)) + .size !== 1 + ) + throw new Error("All requirements must use one destination chain") const source = resolveSourceToken( await fetchSourceTokens(input.sourceChainId, options), diff --git a/src/types.ts b/src/types.ts index 0ca3bb9..b96f370 100644 --- a/src/types.ts +++ b/src/types.ts @@ -63,7 +63,6 @@ export type SquidPublicClient = Pick< | "getBalance" | "getChainId" | "getTransactionCount" - | "prepareTransactionRequest" | "readContract" | "waitForTransactionReceipt" > & { @@ -82,5 +81,5 @@ export type SquidPublicClient = Pick< export type SquidWalletClient = Pick< WalletClient, - "getChainId" | "sendTransaction" + "getChainId" | "prepareTransactionRequest" | "sendTransaction" > & { account: Account }