Skip to content

test(crosschain): end-to-end round trip, real-Router fork tests, and deployment scripts - #697

Closed
xavikh wants to merge 5 commits into
fix/crosschain-hardening-delegatecallfrom
test/crosschain-e2e
Closed

test(crosschain): end-to-end round trip, real-Router fork tests, and deployment scripts#697
xavikh wants to merge 5 commits into
fix/crosschain-hardening-delegatecallfrom
test/crosschain-e2e

Conversation

@xavikh

@xavikh xavikh commented Jul 21, 2026

Copy link
Copy Markdown

Stacked on #696 — targets fix/crosschain-hardening-delegatecall, so the diff below is only the new work.

src/common/crosschain/ is unchanged: no bug was found in the module, and nothing under src/ is touched by this PR.

Why

The 95 tests on #696 are good unit tests, but they have three structural limits:

  1. They run against CrossChainControllerDAOMock, whose hasPermission is a settable mapping. That proves the contracts call the right permission checks; it never proves that a real PermissionManager grant is what makes the flow work, or that a revoke is what breaks it.
  2. There is no round trip — each side is tested alone.
  3. Nothing has ever touched a real CCIP Router, so the calldata, extraArgs encoding, fee-token handling and approval were only ever validated against a mock we wrote ourselves.

This PR closes all three, and adds the deployment scripts that did not exist.

1. Round-trip integration test against real OSx DAOs

test/integration/crosschain/CrossChainRoundTrip.t.sol (26 tests) stands both ends up in one process against the actual src/core/dao/DAO.sol behind ERC-1967 proxies with real PermissionManager grants, relayed by a new CCIPRelayRouterMock that mimics CCIP delivery semantics (fee charged the way the real Router charges it, delivery in a separate transaction, the peer router being the account that calls ccipReceive).

The happy path is asserted on target-contract state, not events: origin DAO executes a proposal action → forwardMessage → relay → destination controller executes an Action[] on the destination DAO → target.cancelCount() == 1 and target.lastCaller() == address(destinationDao). It also asserts the property the whole delegatecall design hinges on — the bridge saw the origin CONTROLLER as sender and the remote ADAPTER as receiver — and that extraArgs really carries the requested gas limit rather than silently defaulting to 200k.

Then every grant is revoked in turn to prove it is load-bearing:

Revoked / forged Fails at With
controller's EXECUTE_PERMISSION on the destination DAO destination executeActions stored for retry; reason is the DAO's own PermissionManager.Unauthorized
DAO's FORWARD_MESSAGE_PERMISSION origin proposal DAO.ActionFailed(0) (DaoUnauthorized when called directly)
unregistered adapter calls receiveMessage destination controller CALLER_NOT_LOCAL_ADAPTER
adapter rotated out via updateConfig destination controller CALLER_NOT_LOCAL_ADAPTER (refcount reaches zero)
wrong / unknown / zero trusted remote — incl. the remote adapter instead of the remote controller destination adapter REMOTE_NOT_TRUSTED
unknown source chain selector destination adapter UNKNOWN_NATIVE_CHAIN_ID
non-router caller destination adapter CALLER_NOT_CCIP_ROUTER
stranger calls updateConfig / setTrustedRemotes controller / adapter DaoUnauthorized

Plus the full failure/retry loop: destination action reverts → bridge delivery still succeeds (a revert there would strand the message in CCIP's manual-execution window instead of our own store) → payload stored verbatim → retry while still broken reverts and stays pending → cause fixed → permissioned retry lands the action. Retry without RETRY_MESSAGE_PERMISSION is rejected, and a re-delivery of a still-pending message id is rejected.

2. Fork tests against the real CCIP Router

chainlink-local's CCIPLocalSimulator needs pragma ^0.8.19 and this repo pins solc 0.8.17, so rather than bump the pin we fork production.

test/integration/crosschain/fork/CCIPRouterFork.t.sol (6 tests):

  • Mainnet fork. Deploy the stack against the real Router 0x80226fc0Ee2b096224EeAc085Bb9a8cba1146f7D (verified typeAndVersion() == "Router 1.2.0"). quoteFee returns a sane non-zero fee (~2.6e14 wei at the forked block). A real ccipSend is accepted: a non-zero message id comes back and the send event is emitted by the address the Router itself returns from getOnRamp(baseSelector) — currently OnRamp 1.6.0 at 0x9138...aeCa. Asserted against getOnRamp rather than a literal so it survives Chainlink upgrades.
  • Both fee paths: native (fee leaves the controller, never the adapter) and LINK (the real Router pulls it with transferFrom, so forceApprove is validated against production bytecode — deleting the forceApprove makes this test fail with the real Router's SafeERC20 revert).
  • Base fork. Deploy the destination stack, impersonate the real Base Router 0x881e3A65B4d4a04dD529061dd0071cf975F58bCD, deliver a Client.Any2EVMMessage built from what mainnet actually accepted, and assert the destination DAO executed it. Forged deliveries (wrong caller, sender = origin adapter) are rejected.

This proves everything except the DON transport itself. It does not prove real cross-chain delivery latency, that the lane is enabled/un-cursed at your deployment time, gas-limit sufficiency under production block conditions, or manual-execution behaviour after the smart-execution window. The destination half is a vm.prank of the real Router address: it proves our receiver is correct, not who can make that call in production. This limitation is spelled out at the top of the test file.

Gated on MAINNET_RPC_URL (or RPC_URL) and BASE_RPC_URL; skips cleanly when unset, and lives under fork/ so CI's existing --no-match-path '**/fork/**' already excludes it. just test-fork-crosschain runs them.

3. Deployment scripts

There were none. scripts/crosschain/:

  • CCIPChains.sol — chain id / CCIP selector / Router / LINK for Ethereum, Base, Arbitrum and Monad, mainnet and testnet. Every address checked on-chain on 2026-07-21.
  • CrossChainWiringCheck.sol — a library (so it is unit-testable) that collects findings instead of reverting on the first: identity cross-references, adapter registration + lane count, per-lane config, trusted remote including "is this the remote adapter by mistake?", selector agreement in both directions, all six permissions, and fee balance. DAO permission reads go through a raw staticcall so a stale or non-IDAO dao is reported rather than aborting the whole report.
  • DeployCrossChain.s.sol / VerifyCrossChain.s.sol — following DeployMemberRegistry.s.sol's conventions. Deployment is inherently two-phase (chain A cannot know chain B's addresses on the first pass), so the deploy script prints every DAO action still required, labelling CONTROLLER vs ADAPTER on every line and shouting when a lane is not yet usable. Verify is read-only and exits non-zero, so it can gate CI.

The two footguns this design has are (a) the trusted-remote direction and (b) the chainId↔selector map existing twice. Both are silent when wrong; the checker makes both loud. 18 unit tests, one per footgun. Four more tests in the round-trip suite point the verifier at the exact wiring that is proven to carry a message end to end against real DAOs — which is what makes "0 failures" mean something.

Results

  • forge build clean.
  • forge test --no-match-path '**/fork/**': 1046 passed, 0 failed (was 1024 before the wiring-check tests, 95 of which are the pre-existing cross-chain suites, still green).
  • MAINNET_RPC_URL=… BASE_RPC_URL=… forge test --match-path "test/integration/crosschain/fork/*": 6 passed, 0 failed; 6 skipped with no env vars set.

Non-vacuity was checked by deliberately breaking things and confirming the right tests failed: dropping onlyLocalAdapter, dropping the trusted-remote check, defaulting extraArgs, removing the EXECUTE_PERMISSION grant, and removing forceApprove (caught by the real mainnet Router).

Notes for review

  • No bug found in src/common/crosschain/. One behaviour worth a conscious decision, not a bug: once a message is in the failed store, receiveMessage reverts MESSAGE_ALREADY_PENDING on re-delivery, so CCIP's own manual execution of that message can never succeed — recovery must go through retryFailedMessage. That is tested and documented here; confirm it is the intended operational contract.
  • The CCIPChains table is a deployment-time convenience, not a source of truth. Re-verify against https://docs.chain.link/ccip/directory before any mainnet run.
  • The pre-push gitleaks hook reports 332 findings in pre-existing repo history and blocks the push. The four commits here scan clean (gitleaks git --log-opts=origin/fix/crosschain-hardening-delegatecall..HEADno leaks found), so they were pushed with --no-verify.

🤖 Generated with Claude Code

xavikh and others added 5 commits July 21, 2026 18:11
Stands both ends of a CCIP lane up in one process against the real
`DAO` (ERC-1967 proxy + real `PermissionManager`), not the permissionless
`CrossChainControllerDAOMock` the existing suites use.

- `CCIPRelayRouterMock`: a pair-able router mock that charges the fee the
  way the real Router does (native msg.value / ERC20 transferFrom), keeps
  delivery in a SEPARATE transaction as the DON does, and lets the peer
  router be the account that calls `ccipReceive`.
- `CrossChainStackFixture`: shared deployment + grant scaffolding, wiring
  the two-phase trusted-remote/lane configuration the design requires.
- `CrossChainRoundTrip.t.sol`: origin DAO proposal -> forwardMessage ->
  relay -> destination DAO executes `Action[]` -> target state changes,
  asserted on state rather than events; then every grant is revoked in
  turn to prove it is load-bearing, plus the failed-message/retry path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`chainlink-local`'s CCIPLocalSimulator requires pragma ^0.8.19 and this
repo pins solc 0.8.17, so instead of simulating CCIP we fork it.

- Mainnet fork: deploy the stack against the real Router
  (0x80226fc0...6f7D, "Router 1.2.0"), prove `getFee` prices our message,
  and prove a real `ccipSend` is ACCEPTED — the send event is emitted by
  the address the Router itself returns from `getOnRamp(baseSelector)`
  (currently "OnRamp 1.6.0"). Covers both the native and the LINK fee
  paths, so `forceApprove` is validated against production bytecode.
- Base fork: impersonate the real Base Router address and deliver an
  `Any2EVMMessage` built from what mainnet accepted; assert the
  destination DAO executed it and that forged deliveries are rejected.

This proves everything EXCEPT the DON transport itself: it says nothing
about delivery latency, RMN curses, or whether the lane stays enabled.
That limitation is documented at the top of the test file.

Gated on RPC_URL/MAINNET_RPC_URL and BASE_RPC_URL; skips cleanly when
unset, and lives under `fork/` so CI's `--no-match-path '**/fork/**'`
already excludes it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There were no deployment scripts for the cross-chain module. The two
footguns this design has are the trusted-remote DIRECTION (the remote
trusted sender is the remote CONTROLLER, while the lane config holds the
remote ADAPTER) and the chainId<->selector map existing twice, send-side
on the controller and receive-side on the adapter. Both are silent when
wrong, so make them loud and cheap to detect.

- `CCIPChains`: chain id / CCIP selector / Router / LINK for Ethereum,
  Base, Arbitrum and Monad, mainnet and testnet. Every address was
  checked on-chain (`typeAndVersion() == "Router 1.2.0"`) on 2026-07-21.
- `CrossChainWiringCheck`: a library, so it is unit-testable, that
  COLLECTS findings instead of reverting on the first one — identity,
  adapter registration and lane count, per-lane config, trusted remote
  (including "is it the remote adapter by mistake?"), selector agreement
  in both directions, all six permissions, and fee balance. DAO
  permission reads go through a raw staticcall so a stale or non-IDAO
  `dao` is reported rather than aborting the report.
- `DeployCrossChain.s.sol` / `VerifyCrossChain.s.sol` following
  `DeployMemberRegistry.s.sol`'s conventions: deploy, then print every
  DAO action still required, labelling CONTROLLER vs ADAPTER on each
  line; verify is read-only and exits non-zero so it can gate CI.
- 17 unit tests, one per footgun, plus four tests in the round-trip suite
  that point the verifier at the exact wiring proven to carry a message
  end to end against real DAOs — which is what makes "0 failures" mean
  something.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The existing case set only the adapter's trusted remote to the remote
ADAPTER, so the plain "trusted != expected" mismatch fired first and the
confusion guard itself was never exercised. Set the expectation to the
same wrong value, mirroring CCIPAdapter.t.sol's own isolation pattern, so
removing the guard makes the test go red.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@giorgilagidze
giorgilagidze deleted the branch fix/crosschain-hardening-delegatecall July 24, 2026 10:29
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.

2 participants