test(crosschain): end-to-end suite for the CCIP cross-chain module - #700
Open
xavikh wants to merge 10 commits into
Open
test(crosschain): end-to-end suite for the CCIP cross-chain module#700xavikh wants to merge 10 commits into
xavikh wants to merge 10 commits into
Conversation
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
marked this pull request as ready for review
July 25, 2026 09:58
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds an end-to-end test suite for
src/common/crosschain/— 98 tests coveringthe paths that only exist once a message is actually travelling a lane.
The existing unit suites run against
CrossChainControllerDAOMock(a settablehasPermissionmapping), a passive router mock that records rather thandelivers, and an
AdapterMock. Nothing exercised a message: origin DAOproposal → adapter → bridge → remote adapter → remote controller → remote DAO
execution. This does, in two modes.
How CCIP is simulated
In-process (default, no RPC, ~30ms).
CCIPRelayRouterMockis a pair ofrouters modelling both halves of a lane, asynchronously:
ccipSendqueues, anddelivery is a separate call through
CallWithExactGaswith the gas limitdecoded from
extraArgs— verbatim whatRouter.routeMessagedoes. A faileddelivery returns
success = falseand stays queued rather than reverting, whichis what makes CCIP's FAILED-then-manually-executed semantics testable. Both
stacks live in one EVM with
block.chainidflipped between phases, usingproduction chain ids and selectors.
Chainlink's
CCIPLocalSimulatorwas not used: it delivers inline, reverts thesend 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 realRouter.routeMessage—onlyOffRamp,real exact-gas semantics. Highest fidelity available without the DON.
Coverage
PermissionManagerFindings
BaseAdapter.UPDATE_ADAPTER_CONFIG_PERMISSION_IDis declared but unused;trusted remotes are constructor-only, so rotating one needs a redeploy.
Errors.UNEXPECTED_NATIVE_VALUEis unreachable —forwardMessageis not payable.Errorsdoccomments still describing the removed
bridgeChainId.Actionwhosetohas 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 ninechains in
CCIPAdapter's hardcoded selector table are live lanes from mainnet.Running it
Both anvil modes verified: clean anvil via
--fork-url(87 pass), and a locallyforked anvil (11 pass). Full repo suite 1068 passed / 0 failed; upgrade
regression 15 passed. CI already excludes
**/fork/**.See
src/common/crosschain/README.mdfor the module overview and permission table.🤖 Generated with Claude Code