diff --git a/README.md b/README.md index bf991fa..e661624 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,14 @@ 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. +When execution fails after any transaction has been broadcast, the executor +throws `SquidExecutionError`. It carries `completedRoutes` (requirement IDs and +transaction hashes of finished legs), `requirementId` for the failed leg, +`transactionHash` of the most recently broadcast transaction for that leg (an +approval or the route itself), `nativeFee` committed to broadcast transactions +so far, and the underlying error as `cause`. Failures +before any broadcast throw plain errors because nothing has changed on-chain. + ## Browser verification `pnpm browser:check` builds the published entry point, resolves a package-root diff --git a/scripts/check-browser-runtime.mjs b/scripts/check-browser-runtime.mjs index e6d6ac7..6cf8010 100644 --- a/scripts/check-browser-runtime.mjs +++ b/scripts/check-browser-runtime.mjs @@ -12,6 +12,7 @@ const publishedEntry = "./dist/index.js" const expectedExports = [ "NATIVE_TOKEN_ADDRESS", "SQUID_ROUTER_ADDRESS", + "SquidExecutionError", "SquidMinimumAmountError", "assertTrustedSquidQuote", "executeSquidFunding", diff --git a/src/execution.test.ts b/src/execution.test.ts index 461ff10..1bb1c47 100644 --- a/src/execution.test.ts +++ b/src/execution.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest" import { executeSquidFunding, NATIVE_TOKEN_ADDRESS, + SquidExecutionError, type SquidFundingPlan, type SquidPublicClient, type SquidQuote, @@ -615,6 +616,88 @@ describe("guarded Squid execution", () => { expect(secondFails.calls.send).toBe(2) }) + it("reports committed state when execution fails after a broadcast", async () => { + 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 failure = await executeSquidFunding( + input(twoLegs), + dependencies(secondFails, provider({ statuses: ["success", "failed"] })), + ).catch((error: unknown) => error) + if (!(failure instanceof SquidExecutionError)) + throw new Error("Expected a SquidExecutionError") + expect(failure.requirementId).toBe("two") + expect(failure.transactionHash).toBe(`0x${"2".padStart(64, "a")}`) + expect(failure.completedRoutes).toEqual([ + { requirementId: "fund", transactionHash: `0x${"1".padStart(64, "a")}` }, + ]) + expect(failure.nativeFee).toBe(12n) + expect((failure.cause as Error).message).toBe("Squid route failed") + + const timesOut = clients({ + allowance: 10n, + destinationBalances: [0n, 9n, 9n], + }) + const timeout = await executeSquidFunding( + input(), + dependencies(timesOut), + ).catch((error: unknown) => error) + if (!(timeout instanceof SquidExecutionError)) + throw new Error("Expected a SquidExecutionError") + expect(timeout.completedRoutes).toEqual([]) + expect(timeout.transactionHash).toBe(`0x${"1".padStart(64, "a")}`) + expect(timeout.message).toContain("poll limit") + + const preCommit = clients({ allowance: 10n }) + const untouched = await executeSquidFunding( + input(plan(), { maxNativeFee: 5n }), + dependencies(preCommit), + ).catch((error: unknown) => error) + expect(untouched).not.toBeInstanceOf(SquidExecutionError) + expect((untouched as Error).message).toContain("total-native-fee cap") + expect(preCommit.calls.send).toBe(0) + + const reverts = clients({ allowance: 10n, reverted: true }) + const reverted = await executeSquidFunding( + input(), + dependencies(reverts), + ).catch((error: unknown) => error) + if (!(reverted instanceof SquidExecutionError)) + throw new Error("Expected a SquidExecutionError") + expect(reverted.transactionHash).toBe(`0x${"1".padStart(64, "a")}`) + expect(reverted.nativeFee).toBe(6n) + expect((reverted.cause as Error).message).toBe("Transaction reverted") + + const rejectsRoute = clients() + const walletSend = rejectsRoute.wallet.sendTransaction.bind( + rejectsRoute.wallet, + ) + rejectsRoute.wallet.sendTransaction = (async ( + request: Record, + ) => { + if (rejectsRoute.calls.send >= 1) throw new Error("User rejected") + return walletSend(request as never) + }) as SquidWalletClient["sendTransaction"] + const rejected = await executeSquidFunding( + input(), + dependencies(rejectsRoute), + ).catch((error: unknown) => error) + if (!(rejected instanceof SquidExecutionError)) + throw new Error("Expected a SquidExecutionError") + expect(rejected.transactionHash).toBe(`0x${"1".padStart(64, "a")}`) + expect(rejected.nativeFee).toBe(6n) + expect((rejected.cause as Error).message).toBe("User rejected") + }) + it("treats status 404s, thrown fetches, and failed balance reads as pending", async () => { const indexingDelay = clients({ allowance: 10n }) await expect( diff --git a/src/execution.ts b/src/execution.ts index 3d2b98e..b57adcd 100644 --- a/src/execution.ts +++ b/src/execution.ts @@ -19,6 +19,34 @@ import { type Transaction = { to: Address; data: Hex; value: bigint } const MAX_POLL_INTERVAL_MS = 2_147_483_647 +export class SquidExecutionError extends Error { + readonly requirementId: string + readonly transactionHash?: Hash + readonly completedRoutes: SquidExecutionResult["routes"] + readonly nativeFee: bigint + + constructor( + cause: unknown, + context: { + requirementId: string + transactionHash?: Hash + completedRoutes: SquidExecutionResult["routes"] + nativeFee: bigint + }, + ) { + super( + `Execution failed after funds were committed: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ) + this.name = "SquidExecutionError" + this.requirementId = context.requirementId + if (context.transactionHash != null) + this.transactionHash = context.transactionHash + this.completedRoutes = context.completedRoutes + this.nativeFee = context.nativeFee + } +} + function sameAddress(a: Address, b: Address) { return a.toLowerCase() === b.toLowerCase() } @@ -214,6 +242,8 @@ export async function executeSquidFunding( ) const now = () => Math.floor((dependencies.squid.now ?? Date.now)() / 1000) let totalNativeFee = 0n + let committed = false + let activeTransactionHash: Hash | undefined const routes: Array<{ requirementId: string; transactionHash: Hash }> = [] const send = async ( transaction: Transaction, @@ -276,12 +306,16 @@ export async function executeSquidFunding( 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({ ...prepared.request, account: dependencies.walletClient.account, chain: undefined, } as never)) as Hash + // The broadcast is the commitment point: record the hash and fee here + // so a revert or receipt failure still reports what went on-chain. + committed = true + activeTransactionHash = transactionHash + totalNativeFee += prepared.fee const receipt = await dependencies.publicClient.waitForTransactionReceipt({ hash: transactionHash, }) @@ -289,8 +323,7 @@ export async function executeSquidFunding( return transactionHash } - for (let index = 0; index < plan.quotes.length; index += 1) { - const planned = plan.quotes[index] as SquidQuote + const executeQuote = async (planned: SquidQuote, index: number) => { let refreshed = await refresh(planned) assertQuote( planned, @@ -426,7 +459,27 @@ export async function executeSquidFunding( } if (!complete) throw new Error("Squid route did not complete within the poll limit") - routes.push({ requirementId: planned.requirement.id, transactionHash }) + return transactionHash + } + + for (let index = 0; index < plan.quotes.length; index += 1) { + const planned = plan.quotes[index] as SquidQuote + activeTransactionHash = undefined + try { + const transactionHash = await executeQuote(planned, index) + routes.push({ requirementId: planned.requirement.id, transactionHash }) + } catch (error) { + // Before the first broadcast nothing is committed on-chain, so plain + // errors stay plain; after it the host needs the committed state to + // recover. + if (!committed) throw error + throw new SquidExecutionError(error, { + requirementId: planned.requirement.id, + transactionHash: activeTransactionHash, + completedRoutes: [...routes], + nativeFee: totalNativeFee, + }) + } } return { sourceAmount, nativeFee: totalNativeFee, routes } } diff --git a/src/index.test.ts b/src/index.test.ts index cb83883..a196f47 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -143,6 +143,7 @@ describe("Squid funding planning", () => { expect(Object.keys(library).sort()).toEqual([ "NATIVE_TOKEN_ADDRESS", "SQUID_ROUTER_ADDRESS", + "SquidExecutionError", "SquidMinimumAmountError", "assertTrustedSquidQuote", "executeSquidFunding", diff --git a/src/index.ts b/src/index.ts index 194d52d..1505552 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ export { resolveSourceToken } from "./catalog.js" -export { executeSquidFunding } from "./execution.js" +export { executeSquidFunding, SquidExecutionError } from "./execution.js" export { planSquidFunding } from "./planner.js" export { assertTrustedSquidQuote,