Skip to content

test(crosschain): end-to-end suite for the CCIP cross-chain module - #700

Open
xavikh wants to merge 10 commits into
feat/crosschainfrom
test/crosschain-e2e-v2
Open

test(crosschain): end-to-end suite for the CCIP cross-chain module#700
xavikh wants to merge 10 commits into
feat/crosschainfrom
test/crosschain-e2e-v2

Conversation

@xavikh

@xavikh xavikh commented Jul 25, 2026

Copy link
Copy Markdown

Adds an end-to-end test suite for src/common/crosschain/ — 98 tests covering
the paths that only exist once a message is actually travelling a lane.

The existing unit suites run against CrossChainControllerDAOMock (a settable
hasPermission mapping), a passive router mock that records rather than
delivers, and an AdapterMock. Nothing exercised a message: origin DAO
proposal → adapter → bridge → remote adapter → remote controller → remote DAO
execution. This does, in two modes.

How CCIP is simulated

In-process (default, no RPC, ~30ms). CCIPRelayRouterMock is a pair of
routers modelling both halves of a lane, asynchronously: ccipSend queues, and
delivery is a separate call through CallWithExactGas with the gas limit
decoded from extraArgs — verbatim what Router.routeMessage does. A failed
delivery returns success = false and stays queued rather than reverting, which
is what makes CCIP's FAILED-then-manually-executed semantics testable. Both
stacks live in one EVM with block.chainid flipped between phases, using
production chain ids and selectors.

Chainlink's CCIPLocalSimulator was not used: it delivers inline, reverts the
send when the receiver reverts, and hardcodes Sepolia as every message's
source. Only the primitive that is right — CallWithExactGas — is reused.

Fork (real bytecode). Two real forks. The origin uses the real ccipSend;
delivery pranks a real registered OffRamp, discovered from
Router.getOffRamps(), into the real Router.routeMessageonlyOffRamp,
real exact-gas semantics. Highest fidelity available without the DON.

Coverage

Area Tests
Happy path (both directions, multi-action, out-of-order, multi-lane, fees) 12
Retries — both layers — and every payload-failure path 20
Authorization against a real PermissionManager 18
Fees, starvation, broken lanes, sweeps 15
Replay resistance and transaction identity 8
Gas regimes 7
Reentrancy and legitimate chaining 7
Fork, against live mainnet + Base 11

Findings

  • BaseAdapter.UPDATE_ADAPTER_CONFIG_PERMISSION_ID is declared but unused;
    trusted remotes are constructor-only, so rotating one needs a redeploy.
  • Errors.UNEXPECTED_NATIVE_VALUE is unreachable — forwardMessage is not payable.
  • Seven dead error selectors after the simplification, and several Errors doc
    comments still describing the removed bridgeChainId.
  • An Action whose to has no code is reported as a successful execution,
    so a cross-chain proposal naming a target not yet deployed on the destination
    executes as a silent no-op.

Verified live

Both Router addresses answer typeAndVersion() == "Router 1.2.0", and all nine
chains in CCIPAdapter's hardcoded selector table are live lanes from mainnet.

Running it

just test-crosschain          # no RPC needed
just test-crosschain-fork     # needs MAINNET_RPC_URL + BASE_RPC_URL; skips without

Both anvil modes verified: clean anvil via --fork-url (87 pass), and a locally
forked anvil (11 pass). Full repo suite 1068 passed / 0 failed; upgrade
regression 15 passed. CI already excludes **/fork/**.

See src/common/crosschain/README.md for the module overview and permission table.

🤖 Generated with Claude Code

xavikh and others added 10 commits July 25, 2026 01:08
Stands up the fixture the cross-chain E2E suite is built on: two complete
stacks (real OSx DAO + CrossChainController + CCIPAdapter) in one Foundry
process, wired through paired router mocks.

The transport mock is the substantive part. Unlike the existing passive
CCIPRouterMock it models both halves of a lane, and models them
asynchronously: ccipSend queues, and delivery is a separate call that goes
through CallWithExactGas with the gas limit decoded from extraArgs --
verbatim what Router.routeMessage does. A failed delivery returns
success=false and leaves the message queued rather than reverting, so the
suite can exercise CCIP's FAILED-then-manually-executed semantics, which is
the distinction the retry and gas scenarios rest on.

block.chainid is flipped between the send and delivery phases so the
originChainId/destinationChainId stamping and the INCORRECT_CHAIN_MISMATCH
guard are exercised for real. Chain ids and selectors are the production
values from ChainIds.sol.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Section A and G of the E2E path inventory: both directions of a lane,
multi-action payloads including treasury transfers, consecutive and
out-of-order delivery, two lanes from one controller, native and ERC20 fee
accounting, fee starvation, broken lanes and sweeps.

Three of these pin properties that are easy to regress silently:

- the adapter must advertise IAny2EVMMessageReceiver, because the real
  Router's ERC165 probe SKIPS a receiver that fails it and reports the
  delivery as successful -- a false there loses every inbound message with
  no error anywhere;
- a codeless local adapter must revert, since delegatecall into a codeless
  address reports success and would let a proposal "execute" while nothing
  was bridged;
- the ERC20 allowance must be back to zero after a send.

test_e2e_failedSendDoesNotConsumeANonce and
test_e2e_forwardMessageRejectsAttachedValue document two consequences of
the current design: a reverted send rolls its nonce back, and
Errors.UNEXPECTED_NATIVE_VALUE is unreachable because forwardMessage is not
payable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sections B and D. The organising idea, documented at the top of the file, is
that the design has TWO independent retry layers and which one catches a
given failure is not obvious:

- application: receiveMessage try/catches the DAO execution, so a reverting
  payload is caught, stored as Delivered, and recovered with retryMessage
  under RETRY_MESSAGE_PERMISSION;
- bridge: if ccipReceive itself reverts, nothing is stored at all, the
  transaction stays None, and recovery is a permissionless CCIP
  re-execution once the cause is fixed.

Tests read `success` from the delivery helpers as the bridge-level outcome,
so `success && Delivered` is a payload failure and `!success && None` is a
delivery failure.

Notable properties pinned here:

- a retry that fails again stays retryable, because retryMessage's
  optimistic write to Executed is rolled back with the reverting call. The
  opposite ordering would burn the message permanently.
- a tampered envelope -- redirected action or bumped nonce -- hashes to a
  different txId and matches nothing, so holding the retry permission does
  not let anyone swap in a different payload.
- a cleared lane or a rotated adapter rejects an in-flight message at the
  bridge level, and because nothing was stored the message is recoverable by
  re-executing it after the wiring is put back.
- a malformed payload is caught rather than propagated, which is the reason
  abi.decode sits inside executeActions.
- an action pointed at a codeless address is reported as SUCCESS by the raw
  call in DAO.execute, so the message is marked Executed while doing
  nothing. Worth knowing when reviewing cross-chain proposals whose targets
  may not be deployed on the destination yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Section C. CCIP hands the receiver exactly the gas the sender paid for, and
because receiveMessage try/catches the payload while EIP-150 retains 1/64
of the gas for the calling frame, a single number chosen at send time on the
origin chain decides between three outcomes:

  1. enough gas                         -> Executed
  2. too little for the DAO execution,
     enough for the catch to finish     -> Delivered  (permissioned retry)
  3. too little for even that           -> None       (permissionless CCIP
                                                       manual re-execution)

Regime 2 is the one worth internalising: an under-gassed message lands in a
state whose only exit is retryMessage. That is the concrete argument for
holding RETRY_MESSAGE_PERMISSION on an ops multisig rather than solely on
the DAO -- otherwise recovering an under-gassed message costs a full
governance cycle. Regime 3, counter-intuitively, is the friendlier failure.

Also pins that the requested gas limit survives the extraArgs round trip. A
wrong tag or field order there would make CCIP fall back to its 200k default
and shift every boundary underneath these tests.

The gas constants have wide margins but are compiler- and optimiser-
sensitive; if one fails after a toolchain bump the boundary needs
re-measuring, not the test deleting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Section E. A transaction's identity is the hash of the whole envelope, and
two of its fields exist purely to stop replays the bridge itself would
happily carry out.

destinationChainId is the flagship. Controllers are routinely deployed at
the SAME address on every chain, so every destination that trusts "the
origin controller" trusts the same twenty bytes; a Base-bound message clears
the bridge attestation and the adapter's trusted-remote check on Arbitrum
too. test_replay_messageForOneChainIsRejectedOnAnother sets up exactly that
-- asserting first that both destinations really do trust the same address
-- and shows destinationChainId is the only thing that stops it.

originChainId stops the mirror trick: a message cannot be laundered through
a second trusted lane, because the chain it claims must match the chain the
adapter says it arrived from.

Also pinned: the bridge's own messageId has no bearing on identity, so a
bridge that re-labels a message cannot use the new label to replay it; and
the controller/origin fields make otherwise-identical envelopes distinct, so
a redeployed controller cannot collide with its predecessor's history.

The controller field's doc comment records what it is not: it is never
checked against the address the bridge reports as sender. Authentication is
the adapter's trusted-remote check; this field only forms identity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n trap

Sections F and H. The authorization file proves each guard against a REAL
PermissionManager -- a grant makes the check pass, a revoke makes it fail --
which the unit suites cannot do, since they run against a settable mock. It
also covers the inbound-authentication cases that only exist once a message
is on a lane, including a named test for the canonical misconfiguration of
this design: trusting the remote ADAPTER rather than the remote CONTROLLER,
which fails every message closed.

The reentrancy work turned up something worth acting on.

retryMessage re-enters DAO.execute. A DAO can only act by executing a
proposal, which already holds the DAO's reentrancy lock. So granting
RETRY_MESSAGE_PERMISSION to the DAO -- the natural-looking choice, and what
this fixture originally did -- produces a stack in which a failed
cross-chain message can NEVER be retried. The permission has to be held by
an account that calls the controller directly, such as an ops multisig.

test_retry_daoCannotRetryThroughAProposal takes the production path (plugin
-> DAO.execute -> retryMessage) and shows it revert with ActionFailed(0)
wrapping ReentrantCall; test_retry_opsAccountHoldingThePermissionCanRetry
shows the wiring that does work. The same mechanic also means a cross-chain
proposal cannot clear a stuck message on the destination chain, pinned in
Reentrancy.t.sol.

Note that every other retry test in the suite uses vm.prank(dao), a direct
external call from the DAO's address, which is not a path that exists in
production. The fixture keeps the DAO grant and documents the trap rather
than quietly fixing it.

Also covered: a payload cannot feed the controller a new inbound message
(it is not a registered adapter), an adapter cannot inject for a lane it
does not serve, and the legitimate multi-hop path A->B->A works and is paid
by the second chain's controller -- whose fee balance the origin DAO neither
controls nor can see.

Self-replay is not constructible at all: retryMessage takes the envelope and
a transaction's id is that envelope's hash, so a payload naming its own
envelope would have to contain its own hash.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Section I. Two real forks (Ethereum + Base), the production Router on each.

Delivery is the part worth reading. Router.routeMessage is onlyOffRamp and
applies CallWithExactGas, so the suite discovers a REAL registered OffRamp
for the mainnet lane from Router.getOffRamps() and pranks it into
routeMessage. That is the highest-fidelity delivery available without the
DON -- real Router, real caller-authorisation path, real exact-gas semantics
-- and strictly better than pranking the Router into adapter.ccipReceive,
which skips the Router's own logic. Routers carry historical ramps, so the
helper tries each matching one and skips those that revert.

Verified live at time of writing: both Router addresses answer
typeAndVersion() == "Router 1.2.0", all nine chains in CCIPAdapter's
hardcoded selector table are live lanes from mainnet, the real OnRamp prices
our extraArgs and the quote is monotonic in the gas limit (so the limit
really does reach it rather than silently defaulting to 200k), the native
and LINK fee paths are both accepted, and the full loop executes on the
destination DAO.

test_fork_underGassedDeliveryIsRecoverable confirms the third gas regime
from GasLimits.t.sol on production bytecode: the real Router RETURNS
success=false rather than reverting, nothing is stored, and the message
re-executes with a larger limit.

Deviation from the plan: no SharedScenarios.sol. The two fixtures diverge
structurally -- two forks versus chainid flipping -- and the real Router
cannot accept fault injection, so a shared set would have been about four
tests behind a lot of hook indirection. The fork suite instead inherits
CrossChainE2EBase directly and reuses its DAO deployment, permission
wiring, payload/envelope builders and assertions, which is the reuse that
was actually available.

Gated on MAINNET_RPC_URL (or RPC_URL) and BASE_RPC_URL; skips cleanly
without them, and CI already excludes **/fork/**.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`just test-crosschain` runs the in-process suite, which needs no RPC and
works unchanged against a clean local anvil by passing --fork-url.
`just test-crosschain-fork` runs the real-Router suite and skips cleanly
without endpoints.

Both modes verified end to end:
  clean anvil   forge test --fork-url http://127.0.0.1:8545  -> 87 passed
  forked anvil  MAINNET_RPC_URL=http://127.0.0.1:8546        -> 11 passed
Full repo suite is 1068 passed, 0 failed; the new files are lint-clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds src/common/crosschain/README.md: what the module does, the two
properties that are non-obvious when reading the code (the send path is
delegatecalled, so the destination must trust the remote CONTROLLER; and
there are two independent retry layers), the deployment ordering the
constructor-only trusted remotes force, the permission table, and how to run
every suite -- default, clean anvil, real routers, and forked anvil.

The run instructions previously lived only in justfile comments and a GATING
block in the fork test header, which is not where anyone looks. Those are now
one-line pointers to the README instead.

Also trims finding-report commentary out of the test doc comments, leaving
plain statements of what each test asserts. The operational guidance those
paragraphs carried now lives in the README's permission table.

Verified after the trims: 1068 passed / 0 failed, and 11/11 fork tests
against live mainnet + Base.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The `Generated artifacts` workflow is path-filtered on `src/**`, so it had
not run since the cross-chain contracts landed on feat/crosschain. Adding
src/common/crosschain/README.md tripped that filter and surfaced the drift:
abi.ts was missing all six crosschain exports (CrossChainController,
ICrossChainController, BaseAdapter, IBaseAdapter, CCIPAdapter, Errors),
45 exports where the sources produce 51.

Purely generated output -- `bash npm-artifacts/prepare-abi.sh`, additive,
no contract changes on this branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@xavikh
xavikh marked this pull request as ready for review July 25, 2026 09:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant