diff --git a/.changelog/drop-machine-fetch-workarounds.md b/.changelog/drop-machine-fetch-workarounds.md new file mode 100644 index 0000000..86a1caa --- /dev/null +++ b/.changelog/drop-machine-fetch-workarounds.md @@ -0,0 +1,5 @@ +--- +wallet-cli: none +--- + +Remove the CLI's own copies of cross-chain behavior machine-fetch 0.3.2 now provides: the 300-second bridge deadline, the destination-delivery wrapper and its block-floor balance reads, and recording a Stargate send's hash only after broadcasting it. diff --git a/.changelog/request-cross-chain-funding.md b/.changelog/request-cross-chain-funding.md index 2949dd9..9ba1365 100644 --- a/.changelog/request-cross-chain-funding.md +++ b/.changelog/request-cross-chain-funding.md @@ -8,8 +8,8 @@ With `--funding auto` (or `TEMPO_EXPERIMENTAL_CROSS_CHAIN=1`), `tempo request` f - Relay, by contract deposit into Relay's pinned v2 depository: an `approve` and `depositErc20`, validated and re-encoded before signing. - Stargate, from USDC.e, paying LayerZero's fee in LZD within the same transaction. - **Cap:** `--max-funding-spend` caps the source token one funding may spend, bridge fee and gas included. The default is `--max-spend` plus 5% and 0.10. A quote over the cap, or over the wallet's balance, is refused before signing, with the amount it needs. When no route can fund a payment, the error gives each route's reason, such as "Relay is unreachable". -- **Completion:** a bridge counts as completed only once its delivery is visible on the destination chain. Either the payer's balance covers the payment, read no earlier than the block that showed the delivery, or a destination transaction the provider names transferred the output to the payer. +- **Completion:** a bridge counts as completed only once its delivery is visible on the destination chain: the payer's balance covers the payment, read until the deadline. Recovery also accepts a destination transaction the provider names that transferred the output to the payer, so a delivery a payment already spent still settles. - **Deadline:** a bridge has five minutes to deliver. -- **Recovery:** the attempt is saved before funds move. Rerunning a request that a crash or timeout interrupted reconciles its funding instead of bridging again, and a new funding first reconciles earlier ones to the same chain. A Stargate send whose hash was never recorded is found by its `OFTSent` log. +- **Recovery:** the attempt is saved before funds move. Rerunning a request that a crash or timeout interrupted reconciles its funding instead of bridging again, and a new funding first reconciles earlier ones to the same chain. A Stargate send's hash is saved before it is broadcast, so a crash between the two still finds it. A saved send that never landed counts as refunded once it expires. - **Unclear outcomes:** funding whose outcome is unclear fails with `E_PAYMENT_OUTCOME_UNKNOWN` and the `tempo wallet funding recover` command. - Progress goes to stderr. `TEMPO_RELAY_URL` and `TEMPO_LAYERZERO_SCAN_URL` set the providers' APIs. Testnet has no cross-chain route. diff --git a/src/commands/funding/recover.ts b/src/commands/funding/recover.ts index 8bb5945..7224a63 100644 --- a/src/commands/funding/recover.ts +++ b/src/commands/funding/recover.ts @@ -3,7 +3,7 @@ import type { CrossChain } from "machine-fetch/cross-chain"; import { nonDestructive } from "../../cli/command.js"; import { loadAttempt, removeAttempt } from "../../payment/attempts.js"; -import { operationRoute } from "../../request/cross-chain.js"; +import { operationRoute, operationStatus } from "../../request/cross-chain.js"; import { fundingRecoverArgs, fundingRecoverOptions, @@ -44,8 +44,8 @@ const statuses = { } as const; /** - * Reconciles funding operation `id` (Phase 4): asks its route how it stands, never quoting or - * submitting again. A completed or refunded operation, or one that never started, is settled, and + * Reconciles funding operation `id` (Phase 4): asks its route how it stands, and confirms a + * completion on the destination chain, never quoting or submitting again. A completed or refunded operation, or one that never started, is settled, and * its attempt is forgotten so the request can fund anew; a pending one names this command again. */ export async function recoverFunding(id: string, networkFlag: string | undefined) { @@ -73,19 +73,15 @@ export async function recoverFunding(id: string, networkFlag: string | undefined }); const reference = operation.reference ?? operation.quote.reference; const result = await Promise.resolve() - .then(() => - route.status(operation as CrossChain.Operation, { - signal: AbortSignal.timeout(15_000), - }), - ) + .then(() => operationStatus(route, operation as CrossChain.Operation, 15)) .catch(() => { throw walletError({ code: "E_NETWORK", - message: `${route.name === "stargate" ? "LayerZero Scan" : "Relay"} did not report funding operation ${escapeInvisible(id)}; retry shortly.`, + message: `${route.route.name === "stargate" ? "LayerZero Scan" : "Relay"} did not report funding operation ${escapeInvisible(id)}; retry shortly.`, retryable: true, }); }); - const status = statuses[result.status]; + const status = statuses[result]; if (status === "completed" || status === "refunded") await removeAttempt(id); return { ...fields, diff --git a/src/payment/upstream/delivery.ts b/src/payment/upstream/delivery.ts index b165f1b..c178a9a 100644 --- a/src/payment/upstream/delivery.ts +++ b/src/payment/upstream/delivery.ts @@ -1,14 +1,6 @@ -/** - * A route's status, with `destinationTxs`: the destination transactions the provider names for a - * completed operation. The destination balance alone cannot confirm a delivery a payment already - * spent; machine-fetch's `CrossChainStatus` carries the field since 0.3.2 (MF-23). - */ import { Funder } from "machine-fetch"; -import type { CrossChain } from "machine-fetch/cross-chain"; import type { Hex } from "viem"; -export type DeliveredStatus = CrossChain.Status; - /** The 32-byte hex transaction hashes in `value`, ignoring anything else. */ export function transactionHashes(value: unknown): Hex[] { const list = Array.isArray(value) ? value : [value]; diff --git a/src/payment/upstream/relay-route.ts b/src/payment/upstream/relay-route.ts index d97a085..e7df9cd 100644 --- a/src/payment/upstream/relay-route.ts +++ b/src/payment/upstream/relay-route.ts @@ -26,7 +26,7 @@ import { parseAbi, } from "viem"; -import { type DeliveredStatus, namedFetch, transactionHashes } from "./delivery.js"; +import { namedFetch, transactionHashes } from "./delivery.js"; import { FundingLimitError, SourceBalanceError } from "./funding-limit.js"; /** Relay's v2 depository entry point for ERC-20 (TIP-20) deposits. */ @@ -150,7 +150,7 @@ export function relayDepositRoute( options.onProgress?.({ type: "submitted", deposit, request }); return { reference: deposit.requestId }; }, - async status(operation, { signal }): Promise { + async status(operation, { signal }): Promise { const requestId = operation.reference ?? operation.quote.reference; const response = await fetchJson( `${url}/intents/status/v3?requestId=${encodeURIComponent(requestId)}`, diff --git a/src/payment/upstream/stargate-route.ts b/src/payment/upstream/stargate-route.ts index 6d0eae6..247189d 100644 --- a/src/payment/upstream/stargate-route.ts +++ b/src/payment/upstream/stargate-route.ts @@ -12,9 +12,17 @@ */ import { Funder } from "machine-fetch"; import type { CrossChain } from "machine-fetch/cross-chain"; -import { type Address, encodeFunctionData, erc20Abi, type Hex, pad, parseAbi } from "viem"; +import { + type Address, + encodeFunctionData, + erc20Abi, + type Hex, + keccak256, + pad, + parseAbi, +} from "viem"; -import { type DeliveredStatus, namedFetch, transactionHashes } from "./delivery.js"; +import { namedFetch, transactionHashes } from "./delivery.js"; import { FundingLimitError, SourceBalanceError } from "./funding-limit.js"; export const stargateAbi = parseAbi([ @@ -67,16 +75,29 @@ export type StargateRouteOptions = { fetch?: typeof globalThis.fetch; /** Validity of a quote, in seconds; messaging fees move with gas prices. @default 30 */ quoteTtlSeconds?: number; - /** Sign and broadcast exactly `calls`, atomically; resolve with the transaction hash. */ - execute(calls: readonly { to: Address; data: Hex }[], request: CrossChain.Request): Promise; + /** + * Sign exactly `calls` as one atomic transaction that cannot land after `validBefore` (Unix + * seconds), without broadcasting; resolve with the signed, serialized transaction. + */ + sign( + calls: readonly { to: Address; data: Hex }[], + request: CrossChain.Request, + validBefore: number, + ): Promise; + /** Broadcast a transaction `sign` returned and wait for it; resolve with its hash. */ + broadcast(serialized: Hex): Promise; /** Whether the source transaction `hash` reverted, so nothing moved; `undefined` if unknown. */ reverted?(hash: Hex): Promise; - /** The source chain's latest block number, saved with each quote to bound `findSent`. */ - blockNumber?(): Promise; + /** + * Whether the source transaction `hash` can no longer land: the source chain is past `after` + * (Unix seconds) and has no receipt for it. `false` when that cannot be read. + */ + dropped?(hash: Hex, after: number): Promise; /** * The transactions from block `fromBlock` on in which `owner` sent exactly `amountLD` through - * the OFT to `dstEid` (its `OFTSent` events). Recovery uses it when a run was killed after - * broadcasting a send but before recording its hash. + * the OFT to `dstEid` (its `OFTSent` events). Recovery uses it for an operation saved before the + * route recorded its hash ahead of broadcasting, by a run killed after broadcasting a send but + * before recording it. */ findSent?(search: { owner: Address; @@ -96,10 +117,22 @@ type StargateQuoteData = { dstEid: number; amountLD: bigint; nativeFee: bigint; - /** The source chain's block when quoted: no send for this operation is older. */ + /** When the send stops being valid on Tempo, in Unix seconds: the quote's expiry. */ + validBefore?: number | undefined; + /** + * The source chain's block when quoted, saved by earlier versions, which recorded the send's + * hash only after broadcasting: no send for this operation is older. + */ fromBlock?: bigint | undefined; }; +/** + * How long past a send's `validBefore` the source chain must be before a send with no receipt + * counts as dropped, so a load-balanced RPC whose receipts lag its head cannot refund one that + * landed. + */ +const droppedAfterSeconds = 60; + const scanStatuses: Record = { INFLIGHT: "pending", CONFIRMING: "pending", @@ -143,8 +176,9 @@ export function stargateRoute( }; /** - * The one send an operation without a recorded hash made: a run killed between broadcasting and - * recording leaves that window. None, several, or an unbounded search is left for a human. + * The one send an operation without a recorded hash made. Only operations saved before the + * route recorded its hash ahead of broadcasting can lack one: a run killed between broadcasting + * and recording left that window. None, several, or an unbounded search is left for a human. */ const unrecordedSend = async (operation: CrossChain.Operation) => { const data = operation.quote.data as StargateQuoteData | undefined; @@ -204,8 +238,8 @@ export function stargateRoute( const required = amountLD + nativeFee + options.maxGas; if (options.maxSpend !== undefined && required > options.maxSpend) throw new FundingLimitError(required, options.maxSpend, request.from.asset); - const fromBlock = await options.blockNumber?.().catch(() => undefined); - const data: StargateQuoteData = { dstEid, amountLD, nativeFee, fromBlock }; + const expiresAt = Math.min(Math.floor(Date.now() / 1000) + ttl, request.deadline); + const data: StargateQuoteData = { dstEid, amountLD, nativeFee, validBefore: expiresAt }; // The messaging fee is debited in the same token, so it is part of the input: machine-fetch // ranks routes by `amountIn` alone ([MF-22]), and this keeps Stargate's flat fee in the // comparison with Relay. @@ -215,7 +249,7 @@ export function stargateRoute( maxAmountIn: debit, amountOut: request.amountOut, minAmountOut: request.amountOut, - expiresAt: Math.min(Math.floor(Date.now() / 1000) + ttl, request.deadline), + expiresAt, reference: `stargate:${request.operationId}`, data, fees: [ @@ -236,7 +270,7 @@ export function stargateRoute( ], }; }, - async submit({ quote, request }) { + async submit({ quote, request, saveReference }) { const dstEid = check(request); const data = quote.data as StargateQuoteData | undefined; if ( @@ -244,10 +278,15 @@ export function stargateRoute( data.dstEid !== dstEid || typeof data.amountLD !== "bigint" || typeof data.nativeFee !== "bigint" || - data.amountLD + data.nativeFee !== quote.maxAmountIn + data.amountLD + data.nativeFee !== quote.maxAmountIn || + data.validBefore !== quote.expiresAt ) throw new Funder.FundingError("Stargate quote data is malformed"); - const { amountLD, nativeFee } = data; + if (!saveReference) + throw new Funder.FundingError( + "The Stargate route must save its send's hash before sending", + ); + const { amountLD, nativeFee, validBefore } = data; const calls = [ { to: options.token, @@ -294,15 +333,31 @@ export function stargateRoute( }), }, ] as const; - const hash = await options.execute(calls, request); + const serialized = await options.sign(calls, request, validBefore); + const hash = keccak256(serialized); + // Saved before broadcasting: a run killed in between leaves the hash for `status` to find, + // and a send that never lands is dropped once Tempo passes `validBefore`. + await saveReference(hash); + const sent = await options.broadcast(serialized); + if (sent.toLowerCase() !== hash) + throw new Funder.FundingError("The Stargate send's hash is not the one saved"); options.onProgress?.({ type: "submitted", hash, amount: amountLD, fee: nativeFee, request }); return { reference: hash }; }, - async status(operation, { signal }): Promise { + async status(operation, { signal }): Promise { const hash = operation.reference ?? (await unrecordedSend(operation)); // Without the transaction hash there is nothing to look up: reconcile by hand. if (!hash || !/^0x[\da-fA-F]{64}$/.test(hash)) return { status: "actionRequired" }; - if ((await options.reverted?.(hash as Hex)) === true) return { status: "refunded" }; + const reverted = await options.reverted?.(hash as Hex); + if (reverted === true) return { status: "refunded" }; + // A saved send that never landed, nor can any more, moved nothing. + const { validBefore } = (operation.quote.data ?? {}) as StargateQuoteData; + if ( + reverted === undefined && + typeof validBefore === "number" && + (await options.dropped?.(hash as Hex, validBefore + droppedAfterSeconds)) === true + ) + return { status: "refunded" }; const response = await fetchJson(`${scanUrl}/v1/messages/tx/${hash}`, { signal }); if (response.status === 404) return { status: "pending" }; if (!response.ok) diff --git a/src/request/cross-chain-quote.ts b/src/request/cross-chain-quote.ts index 5b1b21b..e81a1b1 100644 --- a/src/request/cross-chain-quote.ts +++ b/src/request/cross-chain-quote.ts @@ -1,9 +1,10 @@ import { Errors as MachineFetchErrors } from "machine-fetch"; -import type { CrossChain } from "machine-fetch/cross-chain"; +import { type CrossChain, CrossChainFunder } from "machine-fetch/cross-chain"; +import { MemoryStore } from "machine-fetch/stores/memory"; import { type Address, formatUnits } from "viem"; import { SourceBalanceError } from "../payment/upstream/funding-limit.js"; -import { type Destination, stargateConfig } from "../shared/chains.js"; +import { type Destination, destinationBalance, stargateConfig } from "../shared/chains.js"; import { sanitizeText } from "../shared/utils.js"; import { bridgeableSources, @@ -14,7 +15,6 @@ import { sourceBalances, } from "./cross-chain.js"; import type { RequestDryRunOutput } from "./dry-run.js"; -import { crossChainDeadlineSeconds } from "./evm-charge.js"; import { parseMaxSlippageBps } from "./funding-mode.js"; import { requestNetwork } from "./identity.js"; import type { RequestOptions } from "./options.js"; @@ -166,11 +166,19 @@ export async function crossChainFundingQuote(options: { const { asset, symbol } = usable; const { from, to } = fundingAccounts(network.chainId, source, asset, destination, payer); const routes = fundingRoutes({ network, asset, destination, payer, symbol }); + // The deadline the funder gives a bridge (always set), which bounds each quote's expiry. + const { defaultDeadlineSeconds } = CrossChainFunder.create({ + from, + to, + routes, + operations: MemoryStore.create(), + balance: (account) => destinationBalance(destination, account.owner), + }); const { answers, chosen } = await quoteRoutes( routes, { amountOut: shortfall, - deadline: Math.floor(Date.now() / 1000) + crossChainDeadlineSeconds, + deadline: Math.floor(Date.now() / 1000) + defaultDeadlineSeconds!, from, to, maxSlippageBps: parseMaxSlippageBps(options.request.maxSlippageBps), diff --git a/src/request/cross-chain.ts b/src/request/cross-chain.ts index ff27553..9818113 100644 --- a/src/request/cross-chain.ts +++ b/src/request/cross-chain.ts @@ -2,8 +2,24 @@ import { Funder } from "machine-fetch"; import { type CrossChain, CrossChainFunder } from "machine-fetch/cross-chain"; import type { Evm } from "machine-fetch/evm"; import { FileStore } from "machine-fetch/stores/file"; -import { type Address, erc20Abi, formatUnits, type Hex, parseAbiItem, parseUnits } from "viem"; -import { getBlockNumber, getLogs, getTransactionReceipt, sendTransactionSync } from "viem/actions"; +import { + type Address, + erc20Abi, + formatUnits, + type Hex, + parseAbiItem, + parseUnits, + TransactionReceiptNotFoundError, +} from "viem"; +import { + getBlock, + getLogs, + getTransactionReceipt, + prepareTransactionRequest, + sendRawTransactionSync, + sendTransactionSync, + signTransaction, +} from "viem/actions"; import { CtaError } from "../cli/incur-compat.js"; import { @@ -15,7 +31,7 @@ import { requestFingerprint, } from "../payment/attempts.js"; import { caip2 } from "../payment/funding.js"; -import { type DeliveredStatus, transactionHashes } from "../payment/upstream/delivery.js"; +import { transactionHashes } from "../payment/upstream/delivery.js"; import { requirementKey } from "../payment/upstream/requirement-key.js"; import { relayDepositRoute } from "../payment/upstream/relay-route.js"; import { stargateRoute } from "../payment/upstream/stargate-route.js"; @@ -24,7 +40,6 @@ import { type Destination, destination as destinationFor, destinationBalance, - destinationBalanceAt, destinationDelivery, relayDepository, stargateConfig, @@ -96,6 +111,14 @@ export function fundingAccounts( type Signer = { sendCalls(calls: readonly { to: Address; data: Hex }[], feeToken: Address): Promise; + /** Signs `calls` as one transaction valid before `validBefore` (Unix seconds), unsent. */ + signCalls( + calls: readonly { to: Address; data: Hex }[], + feeToken: Address, + validBefore: number, + ): Promise; + /** Broadcasts a transaction `signCalls` returned, waits for it, and resolves with its hash. */ + sendSigned(serialized: Hex): Promise; }; /** Stargate's OFT event for a send, by the account that sent it. */ @@ -118,21 +141,15 @@ export function fundingRoutes(options: { stderr?: Pick | undefined; payer?: Address | undefined; symbol?: string | undefined; - /** - * The destination balance a completed bridge must show, in atomic units: the payment's amount. - * @default the operation's own exact output, for reconciliation without the payment - */ - target?: bigint | undefined; - /** Where the routes record the block that showed a delivery; see `DeliveryFloor`. */ - floor?: DeliveryFloor | undefined; }): CrossChain.Route[] { const { network, asset, destination } = options; const symbol = options.symbol ?? tokenSymbol(asset, network.chainId) ?? asset; const write = (text: string) => options.stderr?.write(text); - const send = async (calls: readonly { to: Address; data: Hex }[]) => { + const signer = () => { if (!options.signer) throw new Funder.FundingError("Reconciliation never sends funding"); - return options.signer.sendCalls(calls, asset); + return options.signer; }; + const send = (calls: readonly { to: Address; data: Hex }[]) => signer().sendCalls(calls, asset); const routes: CrossChain.Route[] = []; // Both routes refuse a quote the source account cannot cover, before anything is signed. const sourceClient = createTempoPublicClient(network.chainId); @@ -188,12 +205,20 @@ export function fundingRoutes(options: { ...(process.env.TEMPO_LAYERZERO_SCAN_URL ? { scanUrl: process.env.TEMPO_LAYERZERO_SCAN_URL } : {}), - execute: send, + sign: async (calls, _request, validBefore) => signer().signCalls(calls, asset, validBefore), + broadcast: async (serialized) => signer().sendSigned(serialized), async reverted(hash) { const receipt = await getTransactionReceipt(client, { hash }).catch(() => undefined); return receipt ? receipt.status === "reverted" : undefined; }, - blockNumber: () => getBlockNumber(client), + async dropped(hash, after) { + const block = await getBlock(client).catch(() => undefined); + if (!block || block.timestamp <= BigInt(after)) return false; + return getTransactionReceipt(client, { hash }).then( + () => false, + (error: unknown) => error instanceof TransactionReceiptNotFoundError, + ); + }, async findSent({ owner, dstEid, amountLD, fromBlock }) { const logs = await getLogs(client, { address: stargate.oft, @@ -222,85 +247,62 @@ export function fundingRoutes(options: { }), ); } - return routes.map((route) => - confirmedDelivery( - route, - { - balance: (owner) => destinationBalanceAt(destination, owner), - delivery: (hash, owner) => destinationDelivery(destination, hash, owner), - floor: options.floor, - }, - options.target, - ), - ); + return routes; } /** - * `route` whose `completed` status also requires the delivery to be visible on the destination - * chain: until it is, the operation is still `pending`, so machine-fetch keeps polling within its - * deadline. A provider can report success before the destination chain's RPC reflects the - * transfer, and machine-fetch verifies the balance only once, which then reported a delivered - * bridge as an unknown outcome. - * - * The delivery is visible when the payer's balance covers `target`, or, failing that, when a - * destination transaction the provider names transferred at least the operation's output to the - * payer. The second covers a delivery a payment already spent: a run killed after paying but - * before recording it leaves the operation for `funding recover`, with nothing left to see in the - * balance. + * How the funder confirms a delivery on `destination`, independently of the bridge: the payer's + * USDC balance, polled until it covers the need, and for recovery, what each destination + * transaction the route names transferred to the payer, which still shows a delivery a payment + * has already spent. */ -export function confirmedDelivery( - route: CrossChain.Route, - read: { - balance: (owner: Address) => Promise<{ balance: bigint; block: bigint }>; - delivery: (hash: Hex, owner: Address) => Promise<{ amount: bigint; block: bigint }>; - /** Records the destination block that showed the delivery, for later balance reads. */ - floor?: DeliveryFloor | undefined; - }, - target: bigint | undefined, -): CrossChain.Route { - const seen = (block: bigint) => { - if (read.floor && (read.floor.block === undefined || block > read.floor.block)) - read.floor.block = block; - }; +export function deliveryReaders(destination: Destination) { return { - ...route, - async status(operation, context) { - const result: DeliveredStatus = await route.status(operation, context); - if (result.status !== "completed") return result; - const { amountOut, to } = operation.request; - const held = await read.balance(to.owner).catch(() => undefined); - if (held !== undefined && held.balance >= (target ?? amountOut)) { - seen(held.block); - return result; - } - for (const hash of transactionHashes(result.destinationTxs)) { - const delivered = await read.delivery(hash, to.owner).catch(() => undefined); - if (delivered && delivered.amount >= amountOut) { - seen(delivered.block); - return result; - } - } - return { status: "pending" }; + balance: (account: Evm.TokenAccount) => destinationBalance(destination, account.owner), + delivered: ({ account, transaction }: { account: Evm.TokenAccount; transaction: string }) => { + const [hash] = transactionHashes(transaction); + if (!hash) throw new Funder.FundingError("Destination transaction hash is malformed"); + return destinationDelivery(destination, hash, account.owner); }, }; } -/** - * The destination block from which a confirmed delivery is visible. machine-fetch reads the - * balance once more after a route completes; reading no earlier than this block keeps a - * load-balanced RPC from answering that read from a node that has not seen the delivery. - */ -export type DeliveryFloor = { block?: bigint | undefined }; - /** The route `operation` was submitted through, rebuilt without a signer, for reconciliation. */ export function operationRoute(operation: CrossChain.Operation, network: Network) { const destination = destinationFor(network.chainId, operation.request.to.chainId); if (!destination) return undefined; - return fundingRoutes({ + const route = fundingRoutes({ network, asset: operation.request.from.asset as Address, destination, - }).find((route) => route.name === operation.route); + }).find((candidate) => candidate.name === operation.route); + return route && { route, destination }; +} + +/** + * How `operation` stands with its route, without quoting or submitting: a `completed` status + * counts only once machine-fetch's `recover` confirms the delivery on the destination chain within + * `seconds`, and is `pending` until then. Rejects when the route cannot report. + */ +export async function operationStatus( + { route, destination }: NonNullable>, + operation: CrossChain.Operation, + seconds: number, +): Promise { + const { status } = await route.status(operation, { signal: AbortSignal.timeout(seconds * 1000) }); + if (status !== "completed") return status; + const funder = CrossChainFunder.create({ + from: operation.request.from, + to: operation.request.to, + routes: [route], + operations: FileStore.create(operationsDirectory()), + ...deliveryReaders(destination), + pollIntervalMs: 500, + }); + return funder + .recover(operation.id, { deadlineSeconds: seconds }) + .then(() => "completed" as const) + .catch(() => "pending" as const); } /** @@ -350,8 +352,6 @@ export async function crossChainFunders(options: { destination: Destination; payer: Address; needed: bigint; - /** The payment's amount: the destination balance a completed bridge must show. */ - target: bigint; /** The source token of a saved operation this attempt resumes: it must fund from that token. */ resumeAsset?: Address | undefined; maxSpend?: bigint | undefined; @@ -375,6 +375,25 @@ export async function crossChainFunders(options: { if (status !== "success") throw new Funder.FundingError("Funding transaction reverted"); return transactionHash; }, + async signCalls(calls, feeToken, validBefore) { + const account = (client as { account?: unknown }).account; + const request = await prepareTransactionRequest( + client as never, + { + account, + calls: calls.map((call) => ({ to: call.to, data: call.data })), + feeToken, + validBefore, + } as never, + ); + return signTransaction(client as never, { ...(request as object), account } as never); + }, + async sendSigned(serializedTransaction) { + const receipt = await sendRawTransactionSync(client as never, { serializedTransaction }); + if (receipt.status !== "success") + throw new Funder.FundingError("Funding transaction reverted"); + return receipt.transactionHash; + }, }; const balances = await sourceBalances(network, source); // A resumed operation reconciles from the token it was submitted in, whatever it holds now. @@ -396,7 +415,6 @@ export async function crossChainFunders(options: { ); } const funders = usable.map(({ asset, symbol }) => { - const deliveryFloor: DeliveryFloor = {}; const { from, to } = fundingAccounts( network.chainId, source, @@ -416,12 +434,9 @@ export async function crossChainFunders(options: { stderr: options.stderr, payer: options.payer, symbol, - target: options.target, - floor: deliveryFloor, }), operations: FileStore.create(operationsDirectory()), - balance: (account: Evm.TokenAccount) => - destinationBalance(destination, account.owner, deliveryFloor.block), + ...deliveryReaders(destination), pollIntervalMs: 500, }); }); @@ -486,7 +501,6 @@ export async function crossChainFunding( destination: offer.destination, payer, needed, - target: offer.requirement.amount, resumeAsset: resumed?.request.from.asset as Address | undefined, maxSpend: limit, stderr, @@ -542,17 +556,15 @@ async function reconcileEarlier(attempt: AttemptRecord, network: Network) { } /** - * How an operation stands with its route, without quoting or submitting: `completed` (its USDC was - * delivered, and may since have paid), `refunded`, or `open` (pending, needing action, or - * unreadable). + * How an operation stands, without quoting or submitting: `completed` (its USDC was delivered, + * and may since have paid), `refunded`, or `open` (pending, needing action, or unreadable). */ -export async function settledOutcome(route: CrossChain.Route, operation: CrossChain.Operation) { - try { - const { status } = await route.status(operation, { signal: AbortSignal.timeout(10_000) }); - return status === "completed" || status === "refunded" ? status : ("open" as const); - } catch { - return "open" as const; - } +export async function settledOutcome( + route: NonNullable>, + operation: CrossChain.Operation, +) { + const status = await operationStatus(route, operation, 10).catch(() => "open" as const); + return status === "completed" || status === "refunded" ? status : ("open" as const); } /** E_PAYMENT_OUTCOME_UNKNOWN for funding that may still deliver, with the recover command. */ diff --git a/src/request/evm-charge.ts b/src/request/evm-charge.ts index 67a9891..5f3a9f7 100644 --- a/src/request/evm-charge.ts +++ b/src/request/evm-charge.ts @@ -25,13 +25,6 @@ import type { RequestOptions } from "./options.js"; import { fetchPaidRequest, paymentReceipts } from "./paid.js"; import { MaxSpendExceededError, spendCap } from "./spend.js"; -/** - * How long one cross-chain funding may take, quote to confirmed delivery: machine-fetch's 30 s - * default is shorter than a bridge. Relay deliveries took ~10-20 s and Stargate's LayerZero - * delivery ~25 s on mainnet, before the destination RPC shows the balance. - */ -export const crossChainDeadlineSeconds = 300; - /** * The account that pays EVM offers: a `--private-key` / `TEMPO_PRIVATE_KEY` signer, which is an * EOA on every chain, else the logged-in wallet's remote payer, created on first use. @@ -82,12 +75,9 @@ export async function payEvmAndRetry( const payer = await evmPayer(options); const funding = await crossChainFunding(offer, payer.address, request, options, stderr); const funders = funding?.funders ?? []; - if (funding) - policy = { - ...policy, - maxFundingSpend: funding.maxFundingSpend, - deadlineSeconds: crossChainDeadlineSeconds, - }; + // The policy leaves `deadlineSeconds` unset, so each funder applies its own default: 300 s for + // a bridge, which outlasts machine-fetch's 30 s payment default. + if (funding) policy = { ...policy, maxFundingSpend: funding.maxFundingSpend }; let prepared; try { // The attempt ID was saved before this; the payer travels with it, so funding refuses to diff --git a/src/shared/chains.ts b/src/shared/chains.ts index ad1ba1a..9bcf6db 100644 --- a/src/shared/chains.ts +++ b/src/shared/chains.ts @@ -11,7 +11,7 @@ import { } from "viem"; import { ousd, pathusd, tokens, usdc, usdce } from "viem/tokens"; -import { networkError, usageError } from "./errors.js"; +import { usageError } from "./errors.js"; /** * The chains and tokens funding and payment policy trust. Server-supplied fields never add to @@ -254,35 +254,24 @@ function destinationClient(destination: Destination) { } /** - * How much USDC transaction `hash` on `destination` transferred to `owner`, in atomic units, and - * the block that holds it: `0n` for a reverted or unrelated transaction. Rejects when the receipt - * cannot be read, for example before the destination RPC has the transaction. + * How much USDC transaction `hash` on `destination` transferred to `owner`, in atomic units: `0n` + * for a reverted or unrelated transaction. Rejects when the receipt cannot be read, for example + * before the destination RPC has the transaction. */ export async function destinationDelivery(destination: Destination, hash: Hex, owner: Address) { const receipt = await destinationClient(destination).getTransactionReceipt({ hash }); - const block = receipt.blockNumber; - if (receipt.status !== "success") return { amount: 0n, block }; - const amount = parseEventLogs({ abi: erc20Abi, eventName: "Transfer", logs: receipt.logs }) + if (receipt.status !== "success") return 0n; + return parseEventLogs({ abi: erc20Abi, eventName: "Transfer", logs: receipt.logs }) .filter( (log) => log.address.toLowerCase() === destination.usdc.address.toLowerCase() && log.args.to.toLowerCase() === owner.toLowerCase(), ) .reduce((sum, log) => sum + log.args.value, 0n); - return { amount, block }; } -/** - * `owner`'s USDC balance on `destination`, in atomic units, read at the latest block or, with - * `minBlock`, no earlier than it; rejects when it cannot be read. - */ -export async function destinationBalance( - destination: Destination, - owner: Address, - minBlock?: bigint, -) { - if (minBlock !== undefined) - return (await destinationBalanceAt(destination, owner, minBlock)).balance; +/** `owner`'s USDC balance on `destination`, in atomic units; rejects when it cannot be read. */ +export function destinationBalance(destination: Destination, owner: Address) { return destinationClient(destination).readContract({ address: destination.usdc.address, abi: erc20Abi, @@ -290,43 +279,3 @@ export async function destinationBalance( args: [owner], }); } - -/** - * `owner`'s USDC balance on `destination` and the block it was read at, no earlier than - * `minBlock`. A load-balanced RPC such as mainnet.base.org can answer one request from a node - * that has a block and the next from one that does not yet; reading at a known block keeps a - * delivery seen once from disappearing on the next read. Waits up to `waitMs` for the RPC to - * reach `minBlock`, then rejects. - */ -export async function destinationBalanceAt( - destination: Destination, - owner: Address, - minBlock?: bigint, - waitMs = 20_000, -) { - const client = destinationClient(destination); - const deadline = Date.now() + waitMs; - for (;;) { - try { - const block = await client.getBlockNumber({ cacheTime: 0 }); - if (minBlock === undefined || block >= minBlock) { - const balance = await client.readContract({ - address: destination.usdc.address, - abi: erc20Abi, - functionName: "balanceOf", - args: [owner], - blockNumber: block, - }); - return { balance, block }; - } - } catch (error) { - // A node behind the one that reported the block cannot read at it yet. - if (minBlock === undefined || Date.now() >= deadline) throw error; - } - if (Date.now() >= deadline) - throw networkError(`The ${destination.name} RPC has not reached block ${minBlock}`, { - retryable: true, - }); - await new Promise((resolve) => setTimeout(resolve, 500)); - } -} diff --git a/test/cross-chain-deadline.test.ts b/test/cross-chain-deadline.test.ts new file mode 100644 index 0000000..d9525bf --- /dev/null +++ b/test/cross-chain-deadline.test.ts @@ -0,0 +1,70 @@ +import { Payment } from "machine-fetch"; +import { type CrossChain, CrossChainFunder } from "machine-fetch/cross-chain"; +import { MemoryStore } from "machine-fetch/stores/memory"; +import { describe, expect, it } from "vitest"; + +import { fundingAccounts } from "../src/request/cross-chain.js"; +import type { Destination } from "../src/shared/chains.js"; + +const payer = "0x00000000000000000000000000000000000000b2"; +const source = "0x00000000000000000000000000000000000000a1"; +const asset = "0x20c0000000000000000000000000000000000000"; +const destination: Destination = { + chainId: 8453, + name: "base", + network: "eip155:8453", + usdc: { + address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + decimals: 6, + name: "USD Coin", + version: "2", + }, + rpcUrl: "http://127.0.0.1:1", +}; + +/** The deadline machine-fetch hands a bridge's quote for a payment planned under `policy`. */ +async function quotedDeadline(policy: Payment.plan.Options) { + const deadlines: number[] = []; + const route = { + name: "relay", + quote(request: CrossChain.Request) { + deadlines.push(request.deadline); + throw new Error("quote recorded"); + }, + submit: () => ({ reference: "unused" }), + status: () => ({ status: "pending" as const }), + } as CrossChain.Route; + const funder = CrossChainFunder.create({ + ...fundingAccounts(4217, source, asset, destination, payer), + routes: [route], + operations: MemoryStore.create(), + balance: () => 0n, + }); + const requirement = { + protocol: "x402", + method: "exact", + intent: "charge", + network: destination.network, + asset: destination.usdc.address, + amount: 100_000n, + }; + const plan = await Payment.plan(requirement, { ...policy, funders: [funder] }); + const now = Math.floor(Date.now() / 1000); + await Payment.fund(plan, { operationId: "attempt-1", payer }).catch(() => undefined); + expect(deadlines).toHaveLength(1); + return deadlines[0]! - now; +} + +describe("cross-chain funding deadline", () => { + it("gives a bridge machine-fetch's 300-second cross-chain default when policy sets none", async () => { + // LayerZero delivered a Stargate send in 23 s live; Relay's fill plus Base's RPC lag adds up, + // so the 30-second payment default would time out a delivered bridge. + const seconds = await quotedDeadline({ + maxPayment: [ + { network: destination.network, asset: destination.usdc.address, amount: 1_000_000n }, + ], + }); + expect(seconds).toBeGreaterThanOrEqual(299); + expect(seconds).toBeLessThanOrEqual(301); + }); +}); diff --git a/test/cross-chain-delivery.test.ts b/test/cross-chain-delivery.test.ts index a939fa1..1ede204 100644 --- a/test/cross-chain-delivery.test.ts +++ b/test/cross-chain-delivery.test.ts @@ -1,186 +1,203 @@ -import type { CrossChain } from "machine-fetch/cross-chain"; -import { describe, expect, it } from "vitest"; - -import { confirmedDelivery } from "../src/request/cross-chain.js"; -import { crossChainDeadlineSeconds } from "../src/request/evm-charge.js"; +import { Payment } from "machine-fetch"; +import { type CrossChain, CrossChainFunder } from "machine-fetch/cross-chain"; +import { FileStore } from "machine-fetch/stores/file"; +import { MemoryStore } from "machine-fetch/stores/memory"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { operationsDirectory } from "../src/payment/attempts.js"; +import { deliveryReaders, fundingAccounts, operationStatus } from "../src/request/cross-chain.js"; +import type { Destination } from "../src/shared/chains.js"; +import { useTempHome } from "./helpers.js"; + +const chains = vi.hoisted(() => ({ + balances: [] as (bigint | Error)[], + deliveries: new Map(), + reads: [] as string[], +})); + +vi.mock("../src/shared/chains.js", async (original) => ({ + ...(await original()), + async destinationBalance(_destination: unknown, owner: string) { + chains.reads.push(`balance ${owner}`); + const next = chains.balances.length > 1 ? chains.balances.shift()! : chains.balances[0]!; + if (next instanceof Error) throw next; + return next; + }, + async destinationDelivery(_destination: unknown, hash: string, owner: string) { + chains.reads.push(`delivery ${hash} ${owner}`); + const delivered = chains.deliveries.get(hash) ?? new Error("receipt not found"); + if (delivered instanceof Error) throw delivered; + return delivered; + }, +})); const payer = "0x00000000000000000000000000000000000000b2"; -const signal = new AbortController().signal; -const operation = { - operationId: "attempt-1", - route: "relay", - reference: `0x${"12".repeat(32)}`, - request: { amountOut: 6_000n, to: { owner: payer } }, -} as unknown as CrossChain.Operation; - -const delivery = `0x${"de".repeat(32)}` as const; -const noDelivery = async () => ({ amount: 0n, block: 0n }); -const at = (balance: bigint, block = 1n) => ({ balance, block }); - -function route( - status: CrossChain.Status["status"], - destinationTxs?: readonly `0x${string}`[], -): CrossChain.Route { +const source = "0x00000000000000000000000000000000000000a1"; +const asset = "0x20c0000000000000000000000000000000000000"; +const fill = `0x${"de".repeat(32)}`; +const destination: Destination = { + chainId: 8453, + name: "base", + network: "eip155:8453", + usdc: { + address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + decimals: 6, + name: "USD Coin", + version: "2", + }, + rpcUrl: "http://127.0.0.1:1", +}; +const accounts = fundingAccounts(4217, source, asset, destination, payer); + +beforeEach(() => { + chains.balances = []; + chains.deliveries.clear(); + chains.reads = []; +}); + +/** A route that quotes exactly, submits, and then reports `statuses` in turn, the last one after. */ +function route(...statuses: CrossChain.Status[]): CrossChain.Route & { polls: () => number } { + let polls = 0; return { name: "relay", - quote: async () => { - throw new Error("unused"); - }, - submit: async () => ({ reference: "unused" }), - status: async () => ({ status, ...(destinationTxs ? { destinationTxs } : {}) }), - } as unknown as CrossChain.Route; + quote: (request: CrossChain.Request) => ({ + amountIn: request.amountOut, + maxAmountIn: request.amountOut, + amountOut: request.amountOut, + minAmountOut: request.amountOut, + expiresAt: request.deadline, + reference: `relay:${request.operationId}`, + fees: [], + }), + submit: () => ({ reference: `0x${"12".repeat(32)}` }), + status: () => statuses[Math.min(polls++, statuses.length - 1)]!, + polls: () => polls, + }; } -describe("confirmed cross-chain delivery", () => { - it("keeps a completed bridge pending until the destination balance shows the delivery", async () => { - const balances = [0n, 9_999n, 10_000n]; - const owners: string[] = []; - const confirmed = confirmedDelivery( - route("completed"), +describe("payment funding confirms a delivery on the destination chain", () => { + /** Funds a 10_000 payment through `bridge` with the CLI's readers, as `crossChainFunders` does. */ + async function pay(bridge: CrossChain.Route, deadlineSeconds?: number) { + const funder = CrossChainFunder.create({ + ...accounts, + routes: [bridge], + operations: MemoryStore.create(), + ...deliveryReaders(destination), + pollIntervalMs: 10, + }); + const plan = await Payment.plan( { - balance: async (owner) => (owners.push(owner), at(balances.shift()!)), - delivery: noDelivery, + protocol: "x402", + method: "exact", + intent: "charge", + network: destination.network, + asset: destination.usdc.address, + amount: 10_000n, }, - 10_000n, + { funders: [funder], ...(deadlineSeconds ? { deadlineSeconds } : {}) }, ); + return Payment.fund(plan, { operationId: "attempt-1", payer }); + } - const seen = []; - for (let poll = 0; poll < 3; poll += 1) - seen.push((await confirmed.status(operation, { signal })).status); + it("waits for the balance when the bridge reports success before the RPC shows it", async () => { + // Before funding, then while Base's load-balanced RPC lags behind the provider. + chains.balances = [0n, 0n, 9_999n, 10_000n]; - expect(seen).toEqual(["pending", "pending", "completed"]); - expect(owners).toEqual([payer, payer, payer]); + const prepared = await pay(route({ status: "completed" })); + + expect(prepared?.funding).toMatchObject({ status: "acquired" }); + expect(chains.reads).toEqual(Array(4).fill(`balance ${payer}`)); }); - it("defaults the target to the operation's exact output, for reconciliation", async () => { - const confirmed = (held: bigint) => - confirmedDelivery( - route("completed"), - { balance: async () => at(held), delivery: noDelivery }, - undefined, - ); + it("retries a balance read a lagging node rejects", async () => { + chains.balances = [0n, new Error("header not found"), 10_000n]; - await expect(confirmed(5_999n).status(operation, { signal })).resolves.toEqual({ - status: "pending", - }); - await expect(confirmed(6_000n).status(operation, { signal })).resolves.toEqual({ - status: "completed", - }); + const prepared = await pay(route({ status: "completed" })); + + expect(prepared?.funding).toMatchObject({ status: "acquired" }); }); - it("treats an unreadable balance as not yet delivered", async () => { - const confirmed = confirmedDelivery( - route("completed"), - { - balance: async () => { - throw new Error("rpc down"); - }, - delivery: noDelivery, - }, - 10_000n, - ); + it("does not pay from a destination transaction alone: the balance must cover the payment", async () => { + chains.balances = [0n]; + chains.deliveries.set(fill, 10_000n); - await expect(confirmed.status(operation, { signal })).resolves.toEqual({ status: "pending" }); + await expect(pay(route({ status: "completed", destinationTxs: [fill] }), 1)).rejects.toThrow( + /delivered, but the destination balance did not cover the payment/, + ); }); +}); - it.each(["pending", "refunded", "actionRequired"] as const)( - "passes a %s status through without reading the balance", - async (status) => { - let reads = 0; - const confirmed = confirmedDelivery( - route(status), - { - balance: async () => (reads++, at(0n)), - delivery: async () => (reads++, { amount: 0n, block: 0n }), - }, - 10_000n, - ); - - await expect(confirmed.status(operation, { signal })).resolves.toEqual({ status }); - expect(reads).toBe(0); - }, - ); +describe("reconciliation confirms a completed operation on the destination chain", () => { + /** Saves an operation for 6_000 out, as a payment's funding would have, and reads its status. */ + async function reconcile(bridge: CrossChain.Route) { + await useTempHome(); + const request = { + amountOut: 6_000n, + deadline: Math.floor(Date.now() / 1000) + 300, + ...accounts, + maxSlippageBps: 50, + operationId: "attempt-1", + }; + const operation: CrossChain.Operation = { + id: "attempt-1", + route: bridge.name, + requirementKey: "key", + request, + quote: await bridge.quote({ ...request, signal: new AbortController().signal }), + reference: `0x${"12".repeat(32)}`, + }; + const store = FileStore.create(operationsDirectory()); + await store.claim({ ...operation, reference: undefined }); + await store.setReference(operation.id, operation.reference!); + return operationStatus({ route: bridge, destination }, operation, 1); + } + + it("completes once the payer's balance covers the output", async () => { + chains.balances = [6_000n]; + + await expect(reconcile(route({ status: "completed" }))).resolves.toBe("completed"); + }); - it("completes a delivery a payment already spent, once its destination transaction shows it", async () => { + it("completes a delivery a payment already spent, from its destination transaction", async () => { // A run killed after paying leaves the operation submitted, and the balance back at zero. - const reads: [string, string][] = []; - const confirmed = (delivered: bigint | Error) => - confirmedDelivery( - route("completed", [delivery]), - { - balance: async () => at(0n), - delivery: async (hash, owner) => { - reads.push([hash, owner]); - if (delivered instanceof Error) throw delivered; - return { amount: delivered, block: 7n }; - }, - }, - undefined, - ); - - await expect(confirmed(6_000n).status(operation, { signal })).resolves.toMatchObject({ - status: "completed", - }); - expect(reads).toEqual([[delivery, payer]]); - // Less than the output, or a receipt the RPC does not have yet, is not a delivery. - await expect(confirmed(5_999n).status(operation, { signal })).resolves.toEqual({ - status: "pending", - }); - await expect( - confirmed(new Error("receipt not found")).status(operation, { signal }), - ).resolves.toEqual({ status: "pending" }); - }); + chains.balances = [0n]; + chains.deliveries.set(fill, 6_000n); - it("does not take a destination transaction without the provider's completion", async () => { - let reads = 0; - const confirmed = confirmedDelivery( - route("pending", [delivery]), - { - balance: async () => at(0n), - delivery: async () => (reads++, { amount: 10_000n, block: 7n }), - }, - 10_000n, + await expect(reconcile(route({ status: "completed", destinationTxs: [fill] }))).resolves.toBe( + "completed", ); - - await expect(confirmed.status(operation, { signal })).resolves.toMatchObject({ - status: "pending", - }); - expect(reads).toBe(0); + expect(chains.reads).toContain(`delivery ${fill} ${payer}`); }); - it("records the block that showed the delivery, so later balance reads start there", async () => { - const floor: { block?: bigint } = {}; - const byBalance = confirmedDelivery( - route("completed"), - { balance: async () => at(10_000n, 41n), delivery: noDelivery, floor }, - 10_000n, - ); - await byBalance.status(operation, { signal }); - expect(floor.block).toBe(41n); + it.each([ + ["less than the output", 5_999n], + ["a receipt the RPC does not have yet", new Error("receipt not found")], + ])("keeps a completion pending when the transaction shows %s", async (_, delivered) => { + chains.balances = [0n]; + chains.deliveries.set(fill, delivered); - const byFill = confirmedDelivery( - route("completed", [delivery]), - { - balance: async () => at(0n, 50n), - delivery: async () => ({ amount: 6_000n, block: 45n }), - floor, - }, - undefined, - ); - await byFill.status(operation, { signal }); - expect(floor.block).toBe(45n); - // A pending status records nothing. - const pending = confirmedDelivery( - route("pending"), - { balance: async () => at(10_000n, 99n), delivery: noDelivery, floor }, - 10_000n, + await expect(reconcile(route({ status: "completed", destinationTxs: [fill] }))).resolves.toBe( + "pending", ); - await pending.status(operation, { signal }); - expect(floor.block).toBe(45n); }); - it("gives cross-chain funding minutes, not machine-fetch's 30-second default", () => { - // LayerZero delivered a Stargate send in 23 s live; Relay's fill plus Base's RPC lag adds up. - expect(crossChainDeadlineSeconds).toBeGreaterThanOrEqual(120); + it.each(["pending", "refunded", "actionRequired"] as const)( + "passes a %s status through without reading the destination chain", + async (status) => { + chains.deliveries.set(fill, 6_000n); + + await expect(reconcile(route({ status, destinationTxs: [fill] }))).resolves.toBe(status); + expect(chains.reads).toEqual([]); + }, + ); + + it("rejects when the route cannot report", async () => { + const bridge = route(); + const failing = { + ...bridge, + status: () => Promise.reject(new Error("Relay is unreachable")), + }; + + await expect(reconcile(failing)).rejects.toThrow("Relay is unreachable"); }); }); diff --git a/test/e2e/cross-chain.e2e.ts b/test/e2e/cross-chain.e2e.ts index 79c1ba7..cf52db6 100644 --- a/test/e2e/cross-chain.e2e.ts +++ b/test/e2e/cross-chain.e2e.ts @@ -1,4 +1,4 @@ -import { readdir, rm } from "node:fs/promises"; +import { readdir, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { erc20Abi } from "viem"; @@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, inject, test } from "vitest"; import { cliEnv, devChains, runCli, startCli } from "./cli.js"; import { laggingRpc } from "./lagging-rpc.js"; -import { evmBalance, evmClient, tempoBalance } from "./localnet.js"; +import { evmBalance, evmClient, tempoBalance, tempoClient } from "./localnet.js"; import { type HostileQuote, type StandInRelay, standInRelay } from "./relay.js"; import { type StandInStargate, standInStargate } from "./stargate.js"; import { evmServer, type LocalServer, serve } from "./servers.js"; @@ -326,6 +326,11 @@ describe("cross-chain funding", () => { JSON.parse(listed.stdout) as { operations: { id: string; route: string | null }[] } ).operations; expect(operation).toMatchObject({ route: "stargate" }); + // The send's hash was saved before it was broadcast. + const operations = join(wallet.home, ".tempo", "wallet", "funding", "operations"); + const references = (await readdir(operations)).filter((name) => name.endsWith(".reference")); + expect(references).toHaveLength(1); + expect(await readFile(join(operations, references[0]!), "utf8")).toContain(stargate.sent[0]); const pending = await runCli( "wallet", ["funding", "recover", operation!.id, "--format", "json"], @@ -345,19 +350,28 @@ describe("cross-chain funding", () => { expect(await evmBalance(net, net.evmUsdc, endpoint.seller)).toBe(100_000n); }); - test("finds a Stargate send whose hash was never recorded, instead of asking for a human", async () => { + test("finds the Stargate send of an operation saved without its hash, from before it was saved first", async () => { const wallet = await localWallet(net, { funds: [{ token: net.usdc, limit: 1_000_000n }] }); const endpoint = await x402Endpoint(); + const fromBlock = await tempoClient(net).getBlockNumber(); stargate.hold(true); const first = startCli("request", [endpoint.url], env(wallet)); await stargate.waitForSends(1); first.child.kill("SIGKILL"); await first.done; - // A kill between broadcasting and recording leaves the operation without its hash. + // Earlier versions saved the send's hash only after broadcasting, and the quote's block for + // the search: a kill in between left such an operation without its hash. const operations = join(wallet.home, ".tempo", "wallet", "funding", "operations"); - for (const name of await readdir(operations)) - if (name.endsWith(".reference")) await rm(join(operations, name)); + for (const name of await readdir(operations)) { + const path = join(operations, name); + if (name.endsWith(".reference")) await rm(path); + if (!name.endsWith(".json")) continue; + const record = JSON.parse(await readFile(path, "utf8")); + const { validBefore: _, ...data } = record.operation.quote.data; + record.operation.quote.data = { ...data, fromBlock: `~machine-fetch:bigint:${fromBlock}` }; + await writeFile(path, JSON.stringify(record)); + } const listed = await runCli("wallet", ["funding", "list", "--format", "json"], env(wallet)); const [operation] = (JSON.parse(listed.stdout) as { operations: { id: string }[] }).operations; diff --git a/test/stargate-route.test.ts b/test/stargate-route.test.ts index 36def8b..025b429 100644 --- a/test/stargate-route.test.ts +++ b/test/stargate-route.test.ts @@ -1,5 +1,13 @@ import type { CrossChain } from "machine-fetch/cross-chain"; -import { decodeFunctionData, encodeFunctionData, erc20Abi, getAddress, type Hex, pad } from "viem"; +import { + decodeFunctionData, + encodeFunctionData, + erc20Abi, + getAddress, + type Hex, + keccak256, + pad, +} from "viem"; import { describe, expect, it } from "vitest"; import { FundingLimitError, SourceBalanceError } from "../src/payment/upstream/funding-limit.js"; @@ -15,7 +23,8 @@ const oft = "0x8c76e2F6C5ceDA9AA7772e7efF30280226c44392"; const wrapper = "0x0cEb237E109eE22374a567c6b09F373C73FA4cBb"; const wallet = "0x00000000000000000000000000000000000000a1"; const payer = "0x00000000000000000000000000000000000000b2"; -const hash = `0x${"12".repeat(32)}` as Hex; +const signed = "0x76c0ffee" as Hex; +const hash = keccak256(signed); const request: CrossChain.Request = { amountOut: 1_000_000n, @@ -54,6 +63,7 @@ function route(options: Options = {}) { const feeBps = options.feeBps ?? 1n; const reads: { functionName: string; args: readonly unknown[] }[] = []; const executed: (readonly { to: string; data: Hex }[])[] = []; + const steps: string[] = []; const client = { async readContract({ functionName, args }: { functionName: string; args: readonly unknown[] }) { reads.push({ functionName, args }); @@ -82,13 +92,25 @@ function route(options: Options = {}) { client, maxGas: 20_000n, fetch, - execute: async (calls) => { + sign: async (calls, _request, validBefore) => { executed.push(calls); + steps.push(`sign before ${validBefore}`); + return signed; + }, + broadcast: async (serialized) => { + steps.push(`broadcast ${serialized}`); return hash; }, ...options, }); - return { stargate, reads, executed }; + /** Submits `quoted` the way machine-fetch does, saving the reference the route names. */ + const submit = ( + quoted: CrossChain.BoundedQuote, + saveReference = async (value: string) => { + steps.push(`save ${value}`); + }, + ) => stargate.submit({ quote: quoted, request, saveReference }); + return { stargate, reads, executed, steps, submit }; } describe("Stargate route quotes", () => { @@ -167,15 +189,9 @@ describe("Stargate route quotes", () => { describe("Stargate route submission", () => { it("sends exactly the fee wrap, approvals, and sendToken in one batch", async () => { - const { stargate, executed } = route(); + const { stargate, executed, submit } = route(); const quoted = await stargate.quote(request); - await expect( - stargate.submit({ - quote: quoted, - request, - limits: { maxAmountIn: quoted.maxAmountIn, fees: quoted.fees, expiresAt: quoted.expiresAt }, - } as never), - ).resolves.toEqual({ reference: hash }); + await expect(submit(quoted)).resolves.toEqual({ reference: hash }); expect(executed).toHaveLength(1); const [calls] = executed; const approve = (spender: string, amount: bigint) => @@ -215,24 +231,61 @@ describe("Stargate route submission", () => { ]); }); - it("refuses saved quote data that disagrees with the bounded quote", async () => { - const { stargate, executed } = route(); + it("signs a send valid until the quote expires, and saves its hash before broadcasting it", async () => { + const { stargate, steps, submit } = route(); + const quoted = await stargate.quote(request); + expect(quoted.data).toMatchObject({ validBefore: quoted.expiresAt }); + + await submit(quoted); + + expect(steps).toEqual([ + `sign before ${quoted.expiresAt}`, + `save ${hash}`, + `broadcast ${signed}`, + ]); + }); + + it("never broadcasts a send whose hash could not be saved", async () => { + const { stargate, steps, submit } = route(); const quoted = await stargate.quote(request); - const tampered = { ...quoted, data: { ...(quoted.data as object), amountLD: 2_000_000n } }; + await expect( - stargate.submit({ quote: tampered, request, limits: tampered } as never), - ).rejects.toThrow("Stargate quote data is malformed"); + submit(quoted, async () => { + throw new Error("disk full"); + }), + ).rejects.toThrow("disk full"); + await expect(stargate.submit({ quote: quoted, request })).rejects.toThrow( + "must save its send's hash before sending", + ); + expect(steps.filter((step) => step.startsWith("broadcast"))).toEqual([]); + }); + + it("rejects a broadcast whose hash is not the one saved", async () => { + const { stargate, submit } = route({ broadcast: async () => `0x${"34".repeat(32)}` }); + await expect(submit(await stargate.quote(request))).rejects.toThrow( + "The Stargate send's hash is not the one saved", + ); + }); + + it.each([ + ["amount", { amountLD: 2_000_000n }], + ["expiry", { validBefore: 1 }], + ])("refuses saved quote data whose %s disagrees with the bounded quote", async (_, change) => { + const { stargate, executed, submit } = route(); + const quoted = await stargate.quote(request); + const tampered = { ...quoted, data: { ...(quoted.data as object), ...change } }; + await expect(submit(tampered)).rejects.toThrow("Stargate quote data is malformed"); expect(executed).toHaveLength(0); }); }); describe("Stargate route status", () => { - const operation = (reference?: string) => + const operation = (reference?: string, data?: Record) => ({ operationId: "attempt-1", route: "stargate", reference, - quote: { reference: "stargate:attempt-1" }, + quote: { reference: "stargate:attempt-1", data }, }) as never; const signal = new AbortController().signal; @@ -283,6 +336,52 @@ describe("Stargate route status", () => { }); }); + describe("a saved send that never landed", () => { + const quoted = { dstEid: 30184, amountLD: 1_000_100n, nativeFee: 258_429n, validBefore: 1_000 }; + + it("is refunded once Tempo is a minute past its validity and has no receipt", async () => { + const asked: [string, number][] = []; + const { stargate } = route({ + reverted: async () => undefined, + dropped: async (sent, after) => (asked.push([sent, after]), true), + }); + await expect(stargate.status(operation(hash, quoted), { signal })).resolves.toEqual({ + status: "refunded", + }); + expect(asked).toEqual([[hash, 1_060]]); + }); + + it("is tracked with LayerZero while it may still land", async () => { + const { stargate } = route({ + reverted: async () => undefined, + dropped: async () => false, + scan: () => new Response("", { status: 404 }), + }); + await expect(stargate.status(operation(hash, quoted), { signal })).resolves.toEqual({ + status: "pending", + }); + }); + + it.each([ + ["a send that landed", false, quoted], + [ + "an operation saved before sends had a validity", + undefined, + { ...quoted, validBefore: undefined }, + ], + ])("is never assumed for %s", async (_, reverted, data) => { + let asked = false; + const { stargate } = route({ + reverted: async () => reverted, + dropped: async () => ((asked = true), true), + }); + await expect(stargate.status(operation(hash, data), { signal })).resolves.toMatchObject({ + status: "completed", + }); + expect(asked).toBe(false); + }); + }); + it("needs reconciliation by hand without a transaction hash", async () => { await expect(route().stargate.status(operation(undefined), { signal })).resolves.toEqual({ status: "actionRequired", @@ -290,7 +389,8 @@ describe("Stargate route status", () => { }); describe("a send whose hash was never recorded", () => { - // A run killed after broadcasting but before recording the hash leaves only the quote. + // Before the route saved its hash ahead of broadcasting, a run killed after broadcasting but + // before recording the hash left only the quote, with the block it was made at. const unrecorded = (data: Record) => ({ operationId: "attempt-1", @@ -300,11 +400,6 @@ describe("Stargate route status", () => { }) as never; const data = { dstEid: 30184, amountLD: 1_000_100n, nativeFee: 258_429n, fromBlock: 900n }; - it("records the source block with each quote", async () => { - const quoted = await route({ blockNumber: async () => 900n }).stargate.quote(request); - expect(quoted.data).toMatchObject({ fromBlock: 900n }); - }); - it("finds the one send of exactly that amount from the quote's block on, then tracks it", async () => { const searches: unknown[] = []; const { stargate } = route({