diff --git a/app/src/lib/bridge/validation.test.ts b/app/src/lib/bridge/validation.test.ts index 2c2b029a..b26512d2 100644 --- a/app/src/lib/bridge/validation.test.ts +++ b/app/src/lib/bridge/validation.test.ts @@ -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()); @@ -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"); diff --git a/app/src/lib/bridge/validation.ts b/app/src/lib/bridge/validation.ts index a974c32f..7491af64 100644 --- a/app/src/lib/bridge/validation.ts +++ b/app/src/lib/bridge/validation.ts @@ -6,7 +6,15 @@ import { BRIDGE_CHAINS, bridgeCurrency, bridgeFeePercent, isBridgeAssetSupported // 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; +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)"]); @@ -82,7 +90,7 @@ export function validateRelayChains(value: unknown, route: Pick chain.id === 8453)!.solverAddresses); @@ -111,8 +119,7 @@ export function validateRelayQuote(value: unknown, input: BridgeQuoteRequest, ch 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); @@ -129,7 +136,7 @@ export function validateRelayQuote(value: unknown, input: BridgeQuoteRequest, ch 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); diff --git a/docs/bridge.md b/docs/bridge.md index b56bef1a..0bd25f99 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -55,6 +55,13 @@ These tests never connect a wallet or submit a transaction. `/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. +- 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.