Skip to content
Closed
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
50 changes: 49 additions & 1 deletion app/src/lib/bridge/validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { test } from "node:test";
import { getOrderId, type Order } from "./relay-order.ts";
import { encodeFunctionData, zeroAddress } from "viem";
import { BridgeApiError, DEPOSIT_ABI, QUOTE_TTL_MS, parseBridgeRequest, parseRelayStatus, validateRelayChains, validateRelayQuote } from "./validation.ts";
import { BridgeApiError, DEPOSIT_ABI, QUOTE_TTL_MS, RELAY_ROUTERS, parseBridgeRequest, parseRelayStatus, validateRelayChains, validateRelayQuote } from "./validation.ts";
import { FIXTURE_ADDRESS, FIXTURE_INPUT, FIXTURE_NOW, FIXTURE_ORDER_ID, FIXTURE_REQUEST_ID, relayChainsFixture, relayQuoteFixture } from "./relay.fixture.ts";

const metadata = () => validateRelayChains(relayChainsFixture());
Expand Down Expand Up @@ -94,6 +94,54 @@ test("canonical metadata rejects a changed depository, missing or disabled chain
assert.throws(() => validateRelayChains({ chains: [relayChainsFixture().chains[0]] }), BridgeApiError);
});

// Relay made its v3.2 router the default on 2026-10-26 and keeps v3 live through the transition. While only v3 was
// pinned, every live quote was refused; the captured fixture above still names v3, so both are covered here.
const routerWord = (router: string) => `0x${router.slice(2).padStart(64, "0")}`;
function quoteWithRouters(output: string, refunds: [string, string] = [output, output]) {
const fixture = relayQuoteFixture();
const order = fixture.protocol.v2.orderData;
order.output.extraData = routerWord(output);
order.inputs[0].refunds.forEach((refund, i) => { refund.extraData = routerWord(refunds[i]); });
rebind(fixture);
return fixture;
}
function chainsWithRouter(router: string, only?: number) {
const fixture = relayChainsFixture();
fixture.chains.forEach((chain, i) => { if (only === undefined || only === i) chain.contracts.erc20Router = router; });
return fixture;
}

test("Relay's v3.2 router is accepted in the chain catalogue and in the order, and v3 still is", () => {
const [v32, v3] = RELAY_ROUTERS;
assert.equal(v32, "0x0729e2a4716d9f7dd17858d5d504426d228697f7");
assert.equal(v3, "0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f");
for (const router of RELAY_ROUTERS) {
const chains = validateRelayChains(chainsWithRouter(router));
assert.equal(validateRelayQuote(quoteWithRouters(router), FIXTURE_INPUT, chains, FIXTURE_NOW).amountOut, "9987669548275956", router);
// checksummed or upper-case spellings of the same address are the same router
assert.doesNotThrow(() => validateRelayChains(chainsWithRouter(`0x${router.slice(2).toUpperCase()}`)), router);
}
// mid-transition a catalogue or an order may name both: each one is Relay's, so neither is refused
assert.doesNotThrow(() => validateRelayChains(chainsWithRouter(v3, 1)));
assert.doesNotThrow(() => validate(quoteWithRouters(v32, [v3, v32])));
});

test("a router Relay never published is refused wherever it appears", () => {
const unknown = "0x1111111111111111111111111111111111111111";
for (const index of [0, 1]) assert.throws(() => validateRelayChains(chainsWithRouter(unknown, index)), BridgeApiError, `chain ${index}`);
const [v32] = RELAY_ROUTERS;
assert.throws(() => validate(quoteWithRouters(unknown)), BridgeApiError, "output");
assert.throws(() => validate(quoteWithRouters(v32, [unknown, v32])), BridgeApiError, "source refund");
assert.throws(() => validate(quoteWithRouters(v32, [v32, unknown])), BridgeApiError, "destination refund");
// the address alone is not the word an order carries, and extra bytes around it do not pass either
for (const extraData of [v32, `${routerWord(v32)}00`, `0x${"0".repeat(22)}11${v32.slice(2)}`]) {
const fixture = relayQuoteFixture();
fixture.protocol.v2.orderData.output.extraData = extraData;
rebind(fixture);
assert.throws(() => validate(fixture), BridgeApiError, extraData);
}
});

test("status distinguishes refunds and delays and accepts waiting before hashes exist", () => {
assert.deepEqual(parseRelayStatus({ status: "waiting" }), { status: "waiting", inTxHashes: [], txHashes: [] });
assert.equal(parseRelayStatus({ status: "delayed" }).status, "delayed");
Expand Down
17 changes: 12 additions & 5 deletions app/src/lib/bridge/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,15 @@
// Independently pinned, then checked against GET /chains. Never trust a quote to
// supply the address against which that same quote is validated.
export const RELAY_DEPOSITORY = "0x4cd00e387622c35bddb9b4c962c136462338bc31";
const RELAY_ROUTER = "0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f";
// Relay's execution routers on Base, Robinhood Chain and Arc (the "Cancun" column of its contract-versions table,
// docs.relay.link/references/api/api_resources/contract-addresses): v3.2 first, then v3. Relay made v3.2 the default on
// 2026-10-26 and keeps both live through the transition, telling integrators who pin addresses to allow both. A router
// Relay publishes later is rejected until it is added here: every quote then fails closed, which the opt-in live tests
// (relay.live.test.ts) show at once.
export const RELAY_ROUTERS = ["0x0729e2a4716d9f7dd17858d5d504426d228697f7", "0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f"] as const;

Check warning on line 14 in app/src/lib/bridge/validation.ts

View workflow job for this annotation

GitHub Actions / pr-guard

pr-guard: On-chain address

app/src/lib/bridge/validation.ts: 0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f (already in the repository)

Check warning on line 14 in app/src/lib/bridge/validation.ts

View workflow job for this annotation

GitHub Actions / pr-guard

pr-guard: On-chain address

app/src/lib/bridge/validation.ts: 0x0729e2a4716d9f7dd17858d5d504426d228697f7 (new to the repository)
const isRelayRouter = (value: unknown) => RELAY_ROUTERS.some((router) => sameAddress(value, router));
/** An order names its router as one ABI word in extraData: twelve zero bytes, then the address. */
const isRelayRouterWord = (value: unknown) => RELAY_ROUTERS.some((router) => sameAddress(value, `0x${router.slice(2).padStart(64, "0")}`));
export const DEPOSIT_ABI = parseAbi(["function depositNative(address depositor, bytes32 id) payable"]);
export const ERC20_DEPOSIT_ABI = parseAbi(["function depositErc20(address depositor, address token, uint256 amount, bytes32 id)"]);
export const APPROVAL_ABI = parseAbi(["function approve(address spender, uint256 amount) returns (bool)"]);
Expand Down Expand Up @@ -82,7 +90,7 @@
ensure(chain.vmType === "evm" && chain.disabled === false && chain.depositEnabled === true && chain.blockProductionLagging !== true);
ensure(v2.chainId === BRIDGE_CHAINS[id].key && sameAddress(v2.depository, RELAY_DEPOSITORY));
ensure(sameAddress(currency.address, zeroAddress) && currency.decimals === 18 && currency.symbol === NATIVE_SYMBOLS[id] && currency.supportsBridging === true);
ensure(sameAddress(contracts.erc20Router, RELAY_ROUTER));
ensure(isRelayRouter(contracts.erc20Router));
return chain;
});
const solvers = list(selected.find((chain) => chain.id === 8453)!.solverAddresses);
Expand Down Expand Up @@ -111,8 +119,7 @@
ensure(payment.chainId === source && sameAddress(payment.currency, inputCurrency.address) && uint(payment.amount, true) === input.amount && payment.weight === "1");
const output = object(order.output);
ensure(output.chainId === destination && list(output.calls, 0) && list(order.fees, 0));
const routerData = `0x${RELAY_ROUTER.slice(2).padStart(64, "0")}`;
ensure(sameAddress(output.extraData, routerData));
ensure(isRelayRouterWord(output.extraData));
ensure(typeof output.deadline === "number" && Number.isSafeInteger(output.deadline));
const expiresAt = Math.min(now + QUOTE_TTL_MS, output.deadline * 1000 - DEADLINE_MARGIN_MS);
ensure(expiresAt >= now + 5_000);
Expand All @@ -129,7 +136,7 @@
for (const refund of refunds) {
ensure((refund.chainId === source || refund.chainId === destination) && sameAddress(refund.recipient, input.address));
const refundCurrency = refund.chainId === source ? inputCurrency.address : outputCurrency.address;
ensure(sameAddress(refund.currency, refundCurrency) && uint(refund.minimumAmount) === "0" && refund.deadline === output.deadline && sameAddress(refund.extraData, routerData));
ensure(sameAddress(refund.currency, refundCurrency) && uint(refund.minimumAmount) === "0" && refund.deadline === output.deadline && isRelayRouterWord(refund.extraData));
}
const paymentDetails = object(protocol.paymentDetails);
ensure(paymentDetails.chainId === source && sameAddress(paymentDetails.depository, RELAY_DEPOSITORY) && sameAddress(paymentDetails.currency, inputCurrency.address) && uint(paymentDetails.amount) === input.amount);
Expand Down
7 changes: 7 additions & 0 deletions docs/bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@

`/ui-review-bridge` is a development-only visual fixture with disconnected, quote, quoting, high-fee, expired, error, pending, success, uncertain and refund states, plus approval/recovery states. Its synthetic data cannot execute a transaction, and the route returns 404 outside development. The real header action uses the actual integration.

### Relay router v3.2 (2026-11-10)

- Relay made its v3.2 router (`0x0729e2a4716d9f7dd17858d5d504426d228697f7`) the default on 2026-10-26: Get Chains reports it as `contracts.erc20Router` and every order names it in `extraData`. Only v3 (`0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f`) was pinned, so from that day every quote was refused as unverifiable and the bridge could not be used. Nothing was sent: the check fails closed.

Check warning on line 60 in docs/bridge.md

View workflow job for this annotation

GitHub Actions / pr-guard

pr-guard: On-chain address

docs/bridge.md: 0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f (already in the repository)

Check warning on line 60 in docs/bridge.md

View workflow job for this annotation

GitHub Actions / pr-guard

pr-guard: On-chain address

docs/bridge.md: 0x0729e2a4716d9f7dd17858d5d504426d228697f7 (new to the repository)
- Both routers are now allowed, as Relay's [contract addresses](https://docs.relay.link/references/api/api_resources/contract-addresses) page asks of integrators who pin them: the chain catalogue, the order's output and both refunds must each name one of the two. The depository, the deposit calldata and every other check are unchanged.
- After the change all ten directed routes passed the read-only live tests again. No wallet signed anything.
- The live tests are opt-in, so nothing noticed the outage for two weeks. A scheduled run of `relay.live.test.ts` would have; that is a workflow change, which belongs to the maintainers.

### Wallet test findings (2026-09-16)

- Approval recovery now distinguishes a transaction missing from the queried node, an earlier nonce blocking it, a fee cap below the current base fee, and an RPC outage. These are advisory observations, never permission to resubmit or proof that a missing transaction failed. A fresh matching receipt and allowance are still required.
Expand Down
Loading