Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changelog/drop-machine-fetch-workarounds.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions .changelog/request-cross-chain-funding.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
16 changes: 6 additions & 10 deletions src/commands/funding/recover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 0 additions & 8 deletions src/payment/upstream/delivery.ts
Original file line number Diff line number Diff line change
@@ -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];
Expand Down
4 changes: 2 additions & 2 deletions src/payment/upstream/relay-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -150,7 +150,7 @@ export function relayDepositRoute(
options.onProgress?.({ type: "submitted", deposit, request });
return { reference: deposit.requestId };
},
async status(operation, { signal }): Promise<DeliveredStatus> {
async status(operation, { signal }): Promise<CrossChain.Status> {
const requestId = operation.reference ?? operation.quote.reference;
const response = await fetchJson(
`${url}/intents/status/v3?requestId=${encodeURIComponent(requestId)}`,
Expand Down
95 changes: 75 additions & 20 deletions src/payment/upstream/stargate-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -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<Hex>;
/**
* 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<Hex>;
/** Broadcast a transaction `sign` returned and wait for it; resolve with its hash. */
broadcast(serialized: Hex): Promise<Hex>;
/** Whether the source transaction `hash` reverted, so nothing moved; `undefined` if unknown. */
reverted?(hash: Hex): Promise<boolean | undefined>;
/** The source chain's latest block number, saved with each quote to bound `findSent`. */
blockNumber?(): Promise<bigint>;
/**
* 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<boolean>;
/**
* 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;
Expand All @@ -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<string, "pending" | "completed" | "actionRequired"> = {
INFLIGHT: "pending",
CONFIRMING: "pending",
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand All @@ -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: [
Expand All @@ -236,18 +270,23 @@ export function stargateRoute(
],
};
},
async submit({ quote, request }) {
async submit({ quote, request, saveReference }) {
const dstEid = check(request);
const data = quote.data as StargateQuoteData | undefined;
if (
!data ||
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,
Expand Down Expand Up @@ -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<DeliveredStatus> {
async status(operation, { signal }): Promise<CrossChain.Status> {
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)
Expand Down
16 changes: 12 additions & 4 deletions src/request/cross-chain-quote.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -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),
Expand Down
Loading
Loading