Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions scripts/check-browser-runtime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const publishedEntry = "./dist/index.js"
const expectedExports = [
"NATIVE_TOKEN_ADDRESS",
"SQUID_ROUTER_ADDRESS",
"SquidExecutionError",
"SquidMinimumAmountError",
"assertTrustedSquidQuote",
"executeSquidFunding",
Expand Down
83 changes: 83 additions & 0 deletions src/execution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"
import {
executeSquidFunding,
NATIVE_TOKEN_ADDRESS,
SquidExecutionError,
type SquidFundingPlan,
type SquidPublicClient,
type SquidQuote,
Expand Down Expand Up @@ -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<string, unknown>,
) => {
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(
Expand Down
61 changes: 57 additions & 4 deletions src/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -276,21 +306,24 @@ 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,
})
if (receipt.status !== "success") throw new Error("Transaction reverted")
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,
Expand Down Expand Up @@ -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 }
}
1 change: 1 addition & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ describe("Squid funding planning", () => {
expect(Object.keys(library).sort()).toEqual([
"NATIVE_TOKEN_ADDRESS",
"SQUID_ROUTER_ADDRESS",
"SquidExecutionError",
"SquidMinimumAmountError",
"assertTrustedSquidQuote",
"executeSquidFunding",
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down