From 18120a31fea6f6a7208d09e55df726cabb2e291f Mon Sep 17 00:00:00 2001 From: 0xTimepunk <45543880+0xTimepunk@users.noreply.github.com> Date: Wed, 6 May 2026 12:05:04 +0100 Subject: [PATCH 1/3] feat: add a.DI helper Adds a Foundry test helper for Aave Delivery Infrastructure that composes pigeon's existing CCIP, LayerZero V2, and Hyperlane helpers plus a new ArbitrumNativeHelper primitive. Two entry points cover the shipping a.DI lanes: helpEthToArb relays through the Arbitrum native inbox (decoding the packed retryable, pranking the Inbox-aliased L1 sender on L2), and helpMultiBridge relays through any subset of CCIP/LZ V2/Hyperlane on a multi-bridge consensus lane. The ArbitrumNativeHelper is a standalone primitive reusable for any L1 contract that wraps Inbox.createRetryableTicket. Funding the CCC is the test author's responsibility; the helper does not fund. Tests run against real Eth+Arb forks with the Aave Labs forked deployment. --- README.md | 31 +++++ src/adi/AdiHelper.sol | 124 ++++++++++++++++++ src/arbitrum/ArbitrumNativeHelper.sol | 164 ++++++++++++++++++++++++ test/Adi.t.sol | 178 ++++++++++++++++++++++++++ 4 files changed, 497 insertions(+) create mode 100644 src/adi/AdiHelper.sol create mode 100644 src/arbitrum/ArbitrumNativeHelper.sol create mode 100644 test/Adi.t.sol diff --git a/README.md b/README.md index 4f6aa56..48e7fa3 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ By doing near mainnet testing, developers can quickly check sender authenticatio | Stargate | ✅ | | | Across | ✅ | | | CCIP | ✅ | ✅ | +| a.DI | ✅ | | +| Arbitrum (native) | ✅ | | ## Getting Started ### Installation @@ -83,6 +85,35 @@ ccipHelper.help(CcipHelper.HelpArgs({ `helpWithEstimates(...)` additionally emits `ccipFeePaid`, `ccipFeeToken`, (and `ccipFeeValueJuels` for 1.6) decoded from the source emission. The helper invokes the receiver via `Router.routeMessage` from a prank as the resolved OffRamp, and credits destination tokens to the receiver via `deal()`. It does **not** exercise `TokenPool.releaseOrMint`, rate limits, RMN curse checks, or USDC CCTP attestations. CCIP 1.5 messages with non-empty `tokenAmounts` revert. +a.DI (Aave Delivery Infrastructure — composes `CcipHelper`, `LayerZeroV2Helper`, `HyperlaneHelper`, and `ArbitrumNativeHelper`): + +```solidity +// Eth → Arb (Arbitrum native bridge) +adiHelper.helpEthToArb(AdiHelper.EthToArbArgs({ + l2ForkId: ARB_FORK_ID, + l1Inbox: ARB_INBOX, + l1Bridge: ARB_BRIDGE, + expectedL1CCC: L1_CCC, + logs: logs +})); + +// Arb → Eth (multi-bridge consensus — set address(0) on AMBs you want to skip) +adiHelper.helpMultiBridge(AdiHelper.MultiBridgeArgs({ + dstForkId: ETH_FORK_ID, + dstCcipRouter: ETH_CCIP_ROUTER, + dstCcipChainSelector: ETH_CCIP_CHAIN_SELECTOR, + srcCcipOnRamp: address(0), + dstLzEndpoint: LZ_ENDPOINT_V2, + srcHlMailbox: address(0), // skip Hyperlane on this lane + dstHlMailbox: address(0), + logs: logs +})); +``` + +**Funding**: a.DI's `CrossChainController` must hold native to pay AMB fees. Caller MUST `vm.deal(address(L1_CCC), N ether)` BEFORE invoking `forwardMessage` — the helper does NOT fund the CCC. + +**Over-delivery**: configure only as many AMBs as the destination CCC's consensus threshold (e.g., 2 of 3). Once threshold is hit and the envelope transitions to `Delivered`, additional adapter deliveries can revert with state-check errors. + To display estimations, run the `npm install` and `npm run compile` commands from the [utils/scripts directory](./utils/scripts) before running your tests. Then run tests with the `--ffi` flag and `ENABLE_ESTIMATES` env variable set to `true.` **Gas estimation** is the gas costs required in native tokens to pay for the message delivery. diff --git a/src/adi/AdiHelper.sol b/src/adi/AdiHelper.sol new file mode 100644 index 0000000..4ed734a --- /dev/null +++ b/src/adi/AdiHelper.sol @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +/// library imports +import "forge-std/Test.sol"; + +/// local imports +import {CcipHelper} from "../ccip/CcipHelper.sol"; +import {LayerZeroV2Helper} from "../layerzero-v2/LayerZeroV2Helper.sol"; +import {HyperlaneHelper} from "../hyperlane/HyperlaneHelper.sol"; +import {ArbitrumNativeHelper} from "../arbitrum/ArbitrumNativeHelper.sol"; + +/// @title a.DI Helper +/// @notice Helps simulate Aave Delivery Infrastructure (a.DI) envelope flows by composing pigeon's existing +/// per-AMB helpers (CCIP, LayerZero V2, Hyperlane) and a new Arbitrum-native primitive. +/// @dev a.DI's `CrossChainForwarder.forwardMessage` broadcasts an envelope to a (possibly shuffled) subset of +/// configured bridge adapters; the destination CCC executes the receiver once `requiredConfirmation` adapters +/// have delivered. This helper does not assume which adapters fired — each child helper self-filters its own +/// AMB's events and silently no-ops if none appear. +/// @dev IMPORTANT: a.DI's CCC must hold native to pay AMB fees. Callers MUST `vm.deal(address(CCC), ...)` BEFORE +/// invoking `forwardMessage`. This helper does NOT fund the CCC. +contract AdiHelper is Test { + CcipHelper public immutable ccipHelper; + LayerZeroV2Helper public immutable lzHelper; + HyperlaneHelper public immutable hlHelper; + ArbitrumNativeHelper public immutable arbHelper; + + /// @dev keccak256("TransactionForwardingAttempted(bytes32,bytes32,bytes,uint256,address,address,bool,bytes)") + bytes32 public constant TRANSACTION_FORWARDING_ATTEMPTED_SELECTOR = + 0x935aa87d643578e6395c90fdbd5d50ffee5f2c1f6ce2cd01274740412bb679f4; + + struct EthToArbArgs { + uint256 l2ForkId; + address l1Inbox; // 0 = any + address l1Bridge; // 0 = any + address expectedL1CCC; // expected L1 sender on the retryable (CCC due to delegatecall) + Vm.Log[] logs; + } + + /// @notice Args for any "multi-bridge consensus" lane (Arb→Eth in v1; future Arb→Op etc.). + /// @dev Set any endpoint/router to address(0) to disable that AMB. Each child helper self-filters. + struct MultiBridgeArgs { + uint256 dstForkId; + // CCIP + address dstCcipRouter; // 0 disables CCIP relay + uint64 dstCcipChainSelector; // 0 = no selector filter + address srcCcipOnRamp; // 0 = no emitter filter + // LayerZero V2 + address dstLzEndpoint; // 0 disables LZ relay + // Hyperlane + address srcHlMailbox; // 0 disables HL relay (HyperlaneHelper requires both) + address dstHlMailbox; // 0 disables HL relay + Vm.Log[] logs; + } + + constructor(CcipHelper c, LayerZeroV2Helper l, HyperlaneHelper h, ArbitrumNativeHelper a) { + ccipHelper = c; + lzHelper = l; + hlHelper = h; + arbHelper = a; + vm.makePersistent(address(this)); + vm.makePersistent(address(c)); + vm.makePersistent(address(l)); + vm.makePersistent(address(h)); + vm.makePersistent(address(a)); + } + + ////////////////////////////////////////////////////////////// + // EXTERNAL FUNCTIONS // + ////////////////////////////////////////////////////////////// + + /// @notice Relay an Eth → Arb a.DI envelope via the Arbitrum native bridge. + /// @dev Caller must `vm.deal(L1_CCC, ...)` BEFORE `forwardMessage` to fund the retryable. + /// @param args the relay arguments + function helpEthToArb(EthToArbArgs memory args) external { + ArbitrumNativeHelper.HelpArgs memory inner = ArbitrumNativeHelper.HelpArgs({ + l2ForkId: args.l2ForkId, + l1Inbox: args.l1Inbox, + l1Bridge: args.l1Bridge, + expectedL1Sender: args.expectedL1CCC, + logs: args.logs + }); + arbHelper.help(inner); + } + + /// @notice Relay any multi-bridge consensus a.DI envelope (Arb → Eth canonical lane; future Arb → Op). + /// @dev Each child helper self-filters from `args.logs`. Setting an endpoint to address(0) skips that AMB. + /// @dev OVER-DELIVERY CAVEAT: configure only as many AMBs as the destination CCC's consensus threshold (e.g., + /// 2 of 3). Once the threshold is hit and the envelope transitions to `Delivered`, additional adapter deliveries + /// may revert with state-check errors that propagate out of this function. Pick the AMBs you want to relay via + /// (typically the threshold count) and leave the others at address(0). + /// @param args the relay arguments + function helpMultiBridge(MultiBridgeArgs memory args) external { + if (args.dstCcipRouter != address(0)) { + CcipHelper.HelpArgs memory ccipArgs = CcipHelper.HelpArgs({ + dstForkId: args.dstForkId, + dstRouter: args.dstCcipRouter, + expDstChainSelector: args.dstCcipChainSelector, + srcOnRamp: args.srcCcipOnRamp, + logs: args.logs + }); + ccipHelper.help(ccipArgs); + } + if (args.dstLzEndpoint != address(0)) { + lzHelper.help(args.dstLzEndpoint, args.dstForkId, args.logs); + } + if (args.srcHlMailbox != address(0) && args.dstHlMailbox != address(0)) { + hlHelper.help(args.srcHlMailbox, args.dstHlMailbox, args.dstForkId, args.logs); + } + } + + /// @notice Count source-side `TransactionForwardingAttempted` events with `adapterSuccessful = true`. + /// @dev Useful for tests that want to assert the shuffle picked >= N adapters and they succeeded. + /// @param logs the recorded source-tx logs + /// @return count number of successful forwarding attempts + function countSuccessfulForwards(Vm.Log[] memory logs) external pure returns (uint256 count) { + for (uint256 i; i < logs.length; ++i) { + if (logs[i].topics.length < 4) continue; + if (logs[i].topics[0] != TRANSACTION_FORWARDING_ATTEMPTED_SELECTOR) continue; + // adapterSuccessful is the third indexed field; topic[3] = bytes32(uint256(1)) when true + if (logs[i].topics[3] == bytes32(uint256(1))) ++count; + } + } +} diff --git a/src/arbitrum/ArbitrumNativeHelper.sol b/src/arbitrum/ArbitrumNativeHelper.sol new file mode 100644 index 0000000..e011b96 --- /dev/null +++ b/src/arbitrum/ArbitrumNativeHelper.sol @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +/// library imports +import "forge-std/Test.sol"; + +/// @title Arbitrum Native Helper +/// @notice Helps simulate L1 → Arbitrum native-bridge retryable-ticket delivery in forked tests. +/// @dev Detects `IBridge.MessageDelivered` + `IDelayedMessageProvider.InboxMessageDelivered` events on L1 +/// (paired by `messageNum`), decodes the packed retryable payload to `(to, data)`, switches to the L2 fork, +/// pranks `MessageDelivered.sender` (already aliased by the Inbox), and calls `to.call(data)`. Reusable +/// beyond a.DI for any L1 contract that wraps `Inbox.createRetryableTicket`. +/// @dev Note on aliasing: `AbsInbox._submitRetryable` calls `applyL1ToL2Alias(msg.sender)` BEFORE delivering +/// to the Bridge, so `MessageDelivered.sender` is already the L2 alias. The helper does NOT re-alias. +contract ArbitrumNativeHelper is Test { + /// @dev keccak256("MessageDelivered(uint256,bytes32,address,uint8,address,bytes32,uint256,uint64)") + bytes32 public constant MESSAGE_DELIVERED_SELECTOR = + 0x5e3c1311ea442664e8b1611bfabef659120ea7a0a2cfc0667700bebc69cbffe1; + + /// @dev keccak256("InboxMessageDelivered(uint256,bytes)") + bytes32 public constant INBOX_MESSAGE_DELIVERED_SELECTOR = + 0xff64905f73a67fb594e0f940a8075a860db489ad991e032f48c81123eb52d60b; + + /// @dev L1 → L2 address aliasing offset + uint160 public constant ALIAS_OFFSET = uint160(0x1111000000000000000000000000000000001111); + + /// @dev `MessageDelivered.kind` value for retryable submissions (`L1MessageType_submitRetryableTx`). + uint8 public constant L1_MESSAGE_TYPE_RETRYABLE = 9; + + struct HelpArgs { + uint256 l2ForkId; // destination Arbitrum fork id + address l1Inbox; // optional emitter filter for InboxMessageDelivered (0 = any) + address l1Bridge; // optional emitter filter for MessageDelivered (0 = any) + address expectedL1Sender; // optional raw L1 sender filter (0 = any). Compared via applyL1ToL2Alias. + Vm.Log[] logs; // logs from vm.recordLogs on L1 + } + + error MessageDeliveredMissing(uint256 messageNum); + error RetryableCallFailed(bytes returnData); + error MalformedRetryablePayload(); + + mapping(uint256 => bool) internal _processedMessageNums; + + ////////////////////////////////////////////////////////////// + // EXTERNAL FUNCTIONS // + ////////////////////////////////////////////////////////////// + + /// @notice helps relay one or more L1 → Arbitrum retryables from recorded logs + /// @param args the relay arguments + function help(HelpArgs memory args) external { + _help(args); + } + + /// @notice filter logs to those matching `InboxMessageDelivered` + /// @param logs the recorded logs + /// @param length the expected number of matching logs + /// @return found array of matching logs + function findLogs(Vm.Log[] calldata logs, uint256 length) external pure returns (Vm.Log[] memory found) { + found = new Vm.Log[](length); + uint256 idx; + for (uint256 i; i < logs.length; ++i) { + if (logs[i].topics.length == 0) continue; + if (logs[i].topics[0] == INBOX_MESSAGE_DELIVERED_SELECTOR) { + found[idx++] = logs[i]; + if (idx == length) break; + } + } + } + + /// @notice compute the L2 alias of an L1 address + function applyL1ToL2Alias(address l1) public pure returns (address) { + unchecked { + return address(uint160(l1) + ALIAS_OFFSET); + } + } + + ////////////////////////////////////////////////////////////// + // INTERNAL FUNCTIONS // + ////////////////////////////////////////////////////////////// + + /// @notice scan logs for retryables and relay each to the L2 fork + function _help(HelpArgs memory args) internal { + uint256 prevForkId = vm.activeFork(); + + for (uint256 i; i < args.logs.length; ++i) { + Vm.Log memory l = args.logs[i]; + if (l.topics.length < 2) continue; + if (l.topics[0] != INBOX_MESSAGE_DELIVERED_SELECTOR) continue; + if (args.l1Inbox != address(0) && l.emitter != args.l1Inbox) continue; + + uint256 messageNum = uint256(l.topics[1]); + if (_processedMessageNums[messageNum]) continue; + + (bool foundPair, address aliasedSender, uint8 kind) = + _findPairedMessageDelivered(args.logs, messageNum, args.l1Bridge); + if (!foundPair) revert MessageDeliveredMissing(messageNum); + if (kind != L1_MESSAGE_TYPE_RETRYABLE) continue; + if ( + args.expectedL1Sender != address(0) + && aliasedSender != applyL1ToL2Alias(args.expectedL1Sender) + ) continue; + + bytes memory payload = abi.decode(l.data, (bytes)); + (address to, bytes memory innerData) = _decodeRetryablePayload(payload); + + _processedMessageNums[messageNum] = true; + + vm.selectFork(args.l2ForkId); + vm.prank(aliasedSender, aliasedSender); + (bool ok, bytes memory ret) = to.call(innerData); + if (!ok) revert RetryableCallFailed(ret); + } + + vm.selectFork(prevForkId); + } + + /// @notice locate the `MessageDelivered` event paired with a given `messageNum` + /// @return found whether a paired event was found + /// @return aliasedSender the already-aliased L1 sender stored by the Inbox + /// @return kind the message kind (9 for retryables) + function _findPairedMessageDelivered(Vm.Log[] memory logs, uint256 messageNum, address l1Bridge) + internal + pure + returns (bool found, address aliasedSender, uint8 kind) + { + for (uint256 i; i < logs.length; ++i) { + Vm.Log memory l = logs[i]; + if (l.topics.length < 2) continue; + if (l.topics[0] != MESSAGE_DELIVERED_SELECTOR) continue; + if (l1Bridge != address(0) && l.emitter != l1Bridge) continue; + if (uint256(l.topics[1]) != messageNum) continue; + + // MessageDelivered.data = abi.encode(inbox, kind, sender, messageDataHash, baseFeeL1, timestamp) + (, uint8 _kind, address _sender,,,) = + abi.decode(l.data, (address, uint8, address, bytes32, uint256, uint64)); + return (true, _sender, _kind); + } + return (false, address(0), 0); + } + + /// @notice decode the abi-packed retryable payload from `Inbox.createRetryableTicket` + /// @dev Layout (from `nitro-contracts/AbsInbox._submitRetryable`): + /// uint256(to) | l2CallValue | msg.value | maxSubmissionCost | + /// uint256(excessFeeRefundAddress) | uint256(callValueRefundAddress) | + /// gasLimit | maxFeePerGas | uint256(callDataLength) | data + function _decodeRetryablePayload(bytes memory payload) internal pure returns (address to, bytes memory data) { + if (payload.length < 9 * 32) revert MalformedRetryablePayload(); + + uint256 toWord; + uint256 callDataLength; + assembly { + toWord := mload(add(payload, 32)) + callDataLength := mload(add(payload, mul(32, 9))) + } + + if (payload.length < 9 * 32 + callDataLength) revert MalformedRetryablePayload(); + to = address(uint160(toWord)); + + data = new bytes(callDataLength); + for (uint256 i; i < callDataLength; ++i) { + data[i] = payload[9 * 32 + i]; + } + } +} diff --git a/test/Adi.t.sol b/test/Adi.t.sol new file mode 100644 index 0000000..29d7355 --- /dev/null +++ b/test/Adi.t.sol @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +import "forge-std/Test.sol"; + +import {AdiHelper} from "src/adi/AdiHelper.sol"; +import {ArbitrumNativeHelper} from "src/arbitrum/ArbitrumNativeHelper.sol"; +import {CcipHelper} from "src/ccip/CcipHelper.sol"; +import {LayerZeroV2Helper} from "src/layerzero-v2/LayerZeroV2Helper.sol"; +import {HyperlaneHelper} from "src/hyperlane/HyperlaneHelper.sol"; + +interface ICrossChainController { + function forwardMessage(uint256 destinationChainId, address destination, uint256 gasLimit, bytes memory message) + external + returns (bytes32, bytes32); + + function approveSenders(address[] memory senders) external; + function isSenderApproved(address sender) external view returns (bool); + function owner() external view returns (address); +} + +/// @notice Minimal a.DI receiver portal — implements `IBaseReceiverPortal.receiveCrossChainMessage`. +contract Target { + address public lastOriginSender; + uint256 public lastOriginChainId; + bytes public lastMessage; + uint256 public callCount; + + function receiveCrossChainMessage(address originSender, uint256 originChainId, bytes memory message) external { + lastOriginSender = originSender; + lastOriginChainId = originChainId; + lastMessage = message; + callCount += 1; + } +} + +contract AdiHelperTest is Test { + AdiHelper adiHelper; + ArbitrumNativeHelper arbHelper; + CcipHelper ccipHelper; + LayerZeroV2Helper lzHelper; + HyperlaneHelper hlHelper; + + Target targetEth; + Target targetArb; + + uint256 ETH_FORK_ID; + uint256 ARB_FORK_ID; + + /// @dev Aave Labs forked a.DI deployment (extracted from sample txs in the spec). + address constant L1_CCC = 0x1dbb574D08311eecb57D6616bC8AC3E94a3C6De6; + address constant L2_CCC = 0x0910012Dd03cBA3Ed747cee17d65Ef5B80b490Ba; + address constant CCC_OWNER = 0xfB65C68526969DA4AA3cEDF30b1C53846116D5a2; + + /// @dev AMB infrastructure addresses (mainnet). + address constant ARB_INBOX = 0x4Dbd4fc535Ac27206064B68FfCf827b0A60BAB3f; + address constant ARB_BRIDGE = 0x8315177aB297bA92A06054cE80a67Ed4DBd7ed3a; + address constant ETH_CCIP_ROUTER = 0x80226fc0Ee2b096224EeAc085Bb9a8cba1146f7D; + uint64 constant ETH_CCIP_CHAIN_SELECTOR = 5009297550715157269; + address constant LZ_ENDPOINT_V2 = 0x1a44076050125825900e736c501f859c50fE728c; + address constant ETH_HL_MAILBOX = 0x35231d4c2D8B8ADcB5617A638A0c4548684c7C70; + address constant ARB_HL_MAILBOX = 0x979Ca5202784112f4738403dBec5D0F3B9daabB9; + + uint256 constant ETH_CHAIN_ID = 1; + uint256 constant ARB_CHAIN_ID = 42161; + + string RPC_ETH = vm.envString("ETH_MAINNET_RPC_URL"); + string RPC_ARB = vm.envString("ARBITRUM_MAINNET_RPC_URL"); + + function setUp() external { + ETH_FORK_ID = vm.createSelectFork(RPC_ETH, 25_030_000); + targetEth = new Target(); + + ARB_FORK_ID = vm.createSelectFork(RPC_ARB, 459_800_000); + targetArb = new Target(); + + // Deploy helpers on ARB; AdiHelper constructor calls vm.makePersistent on each so they live across forks. + ccipHelper = new CcipHelper(); + lzHelper = new LayerZeroV2Helper(); + hlHelper = new HyperlaneHelper(); + arbHelper = new ArbitrumNativeHelper(); + adiHelper = new AdiHelper(ccipHelper, lzHelper, hlHelper, arbHelper); + } + + function testAdiEthToArb() external { + vm.selectFork(ETH_FORK_ID); + + // Approve this test as a sender on the L1 CCC (owner-gated). + address[] memory senders = new address[](1); + senders[0] = address(this); + vm.prank(CCC_OWNER); + ICrossChainController(L1_CCC).approveSenders(senders); + + // Fund the L1 CCC to pay the retryable submission fee. + vm.deal(L1_CCC, 5 ether); + + vm.recordLogs(); + // || + // || + // \/ This is the part of the code you could copy to use the AdiHelper in your own tests. + ICrossChainController(L1_CCC).forwardMessage(ARB_CHAIN_ID, address(targetArb), 200_000, abi.encode("hello-arb")); + Vm.Log[] memory logs = vm.getRecordedLogs(); + + adiHelper.helpEthToArb( + AdiHelper.EthToArbArgs({ + l2ForkId: ARB_FORK_ID, + l1Inbox: ARB_INBOX, + l1Bridge: ARB_BRIDGE, + expectedL1CCC: L1_CCC, + logs: logs + }) + ); + // /\ + // || + // || + + vm.selectFork(ARB_FORK_ID); + assertEq(targetArb.callCount(), 1, "Target.receiveCrossChainMessage not called"); + assertEq(targetArb.lastOriginChainId(), ETH_CHAIN_ID, "Origin chainId mismatch"); + assertEq(targetArb.lastOriginSender(), address(this), "Origin sender mismatch"); + assertEq(abi.decode(targetArb.lastMessage(), (string)), "hello-arb", "Message mismatch"); + } + + function testAdiArbToEth() external { + vm.selectFork(ARB_FORK_ID); + + address[] memory senders = new address[](1); + senders[0] = address(this); + vm.prank(CCC_OWNER); + ICrossChainController(L2_CCC).approveSenders(senders); + + // Fund the L2 CCC to pay CCIP+LZ V2+Hyperlane fees. + vm.deal(L2_CCC, 10 ether); + + vm.recordLogs(); + ICrossChainController(L2_CCC).forwardMessage(ETH_CHAIN_ID, address(targetEth), 200_000, abi.encode("hello-eth")); + Vm.Log[] memory logs = vm.getRecordedLogs(); + + // Threshold for Eth-from-Arb is 2/N. Relay via 2 AMBs (CCIP + LZ V2); skip Hyperlane to avoid + // the over-delivery edge case described in `AdiHelper.helpMultiBridge` NatSpec. + adiHelper.helpMultiBridge( + AdiHelper.MultiBridgeArgs({ + dstForkId: ETH_FORK_ID, + dstCcipRouter: ETH_CCIP_ROUTER, + dstCcipChainSelector: ETH_CCIP_CHAIN_SELECTOR, + srcCcipOnRamp: address(0), + dstLzEndpoint: LZ_ENDPOINT_V2, + srcHlMailbox: address(0), + dstHlMailbox: address(0), + logs: logs + }) + ); + + vm.selectFork(ETH_FORK_ID); + assertEq(targetEth.callCount(), 1, "Target.receiveCrossChainMessage not called after consensus"); + assertEq(targetEth.lastOriginChainId(), ARB_CHAIN_ID); + assertEq(targetEth.lastOriginSender(), address(this)); + assertEq(abi.decode(targetEth.lastMessage(), (string)), "hello-eth"); + } + + function testAdiCountSuccessfulForwards() external { + vm.selectFork(ARB_FORK_ID); + + address[] memory senders = new address[](1); + senders[0] = address(this); + vm.prank(CCC_OWNER); + ICrossChainController(L2_CCC).approveSenders(senders); + vm.deal(L2_CCC, 10 ether); + + vm.recordLogs(); + ICrossChainController(L2_CCC).forwardMessage(ETH_CHAIN_ID, address(targetEth), 200_000, abi.encode("count")); + Vm.Log[] memory logs = vm.getRecordedLogs(); + + // Aave Labs Arb→Eth has 3 forwarder adapter pairs configured; expect all 3 to fire successfully. + uint256 successful = adiHelper.countSuccessfulForwards(logs); + assertEq(successful, 3, "expected all 3 AMB adapters to forward successfully"); + } +} From 8b0ba295108308a2f20fbd8b17c36eb99235cf00 Mon Sep 17 00:00:00 2001 From: 0xTimepunk <45543880+0xTimepunk@users.noreply.github.com> Date: Wed, 6 May 2026 12:32:59 +0100 Subject: [PATCH 2/3] fix(adi): read HL mailbox from deployed adapter, enable all 3 AMBs in test --- README.md | 2 +- src/adi/AdiHelper.sol | 10 ++++++---- test/Adi.t.sol | 12 +++++++----- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 48e7fa3..3d90cd7 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ adiHelper.helpMultiBridge(AdiHelper.MultiBridgeArgs({ **Funding**: a.DI's `CrossChainController` must hold native to pay AMB fees. Caller MUST `vm.deal(address(L1_CCC), N ether)` BEFORE invoking `forwardMessage` — the helper does NOT fund the CCC. -**Over-delivery**: configure only as many AMBs as the destination CCC's consensus threshold (e.g., 2 of 3). Once threshold is hit and the envelope transitions to `Delivered`, additional adapter deliveries can revert with state-check errors. +**AMB endpoint addresses**: read each deployed adapter's configured AMB endpoint via its public getter (`HL_MAIL_BOX()`, `LZ_ENDPOINT()`, `getRouter()`) and pass that to `MultiBridgeArgs`. Do NOT hardcode canonical AMB addresses — deployments may use custom AMB infrastructure (different validator sets / ISMs / etc.). To display estimations, run the `npm install` and `npm run compile` commands from the [utils/scripts directory](./utils/scripts) before running your tests. Then run tests with the `--ffi` flag and `ENABLE_ESTIMATES` env variable set to `true.` diff --git a/src/adi/AdiHelper.sol b/src/adi/AdiHelper.sol index 4ed734a..29fc038 100644 --- a/src/adi/AdiHelper.sol +++ b/src/adi/AdiHelper.sol @@ -85,10 +85,12 @@ contract AdiHelper is Test { /// @notice Relay any multi-bridge consensus a.DI envelope (Arb → Eth canonical lane; future Arb → Op). /// @dev Each child helper self-filters from `args.logs`. Setting an endpoint to address(0) skips that AMB. - /// @dev OVER-DELIVERY CAVEAT: configure only as many AMBs as the destination CCC's consensus threshold (e.g., - /// 2 of 3). Once the threshold is hit and the envelope transitions to `Delivered`, additional adapter deliveries - /// may revert with state-check errors that propagate out of this function. Pick the AMBs you want to relay via - /// (typically the threshold count) and leave the others at address(0). + /// @dev Over-delivery is fine: once the destination CCC's threshold is met the envelope transitions to + /// `Delivered`, and subsequent adapter deliveries just increment `confirmations` without re-executing the + /// receiver. The receive path through each adapter must succeed though — the per-adapter `onlyMailBox` / + /// `onlyEndpoint` / `onlyRouter` checks must match the prank target you pass in. Read each deployed adapter's + /// configured AMB endpoint via its public getter (e.g., `HL_MAIL_BOX()`, `LZ_ENDPOINT()`, `getRouter()`) — do + /// NOT hardcode canonical AMB addresses, since deployments may use custom AMB infrastructure. /// @param args the relay arguments function helpMultiBridge(MultiBridgeArgs memory args) external { if (args.dstCcipRouter != address(0)) { diff --git a/test/Adi.t.sol b/test/Adi.t.sol index 29d7355..0b8da1d 100644 --- a/test/Adi.t.sol +++ b/test/Adi.t.sol @@ -58,7 +58,9 @@ contract AdiHelperTest is Test { address constant ETH_CCIP_ROUTER = 0x80226fc0Ee2b096224EeAc085Bb9a8cba1146f7D; uint64 constant ETH_CCIP_CHAIN_SELECTOR = 5009297550715157269; address constant LZ_ENDPOINT_V2 = 0x1a44076050125825900e736c501f859c50fE728c; - address constant ETH_HL_MAILBOX = 0x35231d4c2D8B8ADcB5617A638A0c4548684c7C70; + /// @dev Aave Labs Eth-side HL mailbox is a custom deployment, NOT the canonical + /// `0x35231d4c2D8B8ADcB5617A638A0c4548684c7C70`. Verified via the HL adapter's `HL_MAIL_BOX()` getter. + address constant ETH_HL_MAILBOX = 0xc005dc82818d67AF737725bD4bf75435d065D239; address constant ARB_HL_MAILBOX = 0x979Ca5202784112f4738403dBec5D0F3B9daabB9; uint256 constant ETH_CHAIN_ID = 1; @@ -136,8 +138,8 @@ contract AdiHelperTest is Test { ICrossChainController(L2_CCC).forwardMessage(ETH_CHAIN_ID, address(targetEth), 200_000, abi.encode("hello-eth")); Vm.Log[] memory logs = vm.getRecordedLogs(); - // Threshold for Eth-from-Arb is 2/N. Relay via 2 AMBs (CCIP + LZ V2); skip Hyperlane to avoid - // the over-delivery edge case described in `AdiHelper.helpMultiBridge` NatSpec. + // Relay via all 3 AMBs (CCIP + LZ V2 + Hyperlane). Threshold is 2/N — once met the envelope is `Delivered` + // and any subsequent adapter delivery just increments confirmations without re-executing the receiver. adiHelper.helpMultiBridge( AdiHelper.MultiBridgeArgs({ dstForkId: ETH_FORK_ID, @@ -145,8 +147,8 @@ contract AdiHelperTest is Test { dstCcipChainSelector: ETH_CCIP_CHAIN_SELECTOR, srcCcipOnRamp: address(0), dstLzEndpoint: LZ_ENDPOINT_V2, - srcHlMailbox: address(0), - dstHlMailbox: address(0), + srcHlMailbox: ARB_HL_MAILBOX, + dstHlMailbox: ETH_HL_MAILBOX, logs: logs }) ); From 6f9388bff67fc74a1b953f06516a19d7baf0599e Mon Sep 17 00:00:00 2001 From: 0xTimepunk <45543880+0xTimepunk@users.noreply.github.com> Date: Fri, 8 May 2026 15:17:42 +0100 Subject: [PATCH 3/3] fix(arb): shrink findLogs result to actual match count --- src/arbitrum/ArbitrumNativeHelper.sol | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/arbitrum/ArbitrumNativeHelper.sol b/src/arbitrum/ArbitrumNativeHelper.sol index e011b96..3107096 100644 --- a/src/arbitrum/ArbitrumNativeHelper.sol +++ b/src/arbitrum/ArbitrumNativeHelper.sol @@ -53,8 +53,8 @@ contract ArbitrumNativeHelper is Test { /// @notice filter logs to those matching `InboxMessageDelivered` /// @param logs the recorded logs - /// @param length the expected number of matching logs - /// @return found array of matching logs + /// @param length the maximum number of matching logs to return + /// @return found array of matching logs, sized to the actual number found (≤ length) function findLogs(Vm.Log[] calldata logs, uint256 length) external pure returns (Vm.Log[] memory found) { found = new Vm.Log[](length); uint256 idx; @@ -65,6 +65,10 @@ contract ArbitrumNativeHelper is Test { if (idx == length) break; } } + // shrink array length to the actual match count so trailing zero entries aren't returned + assembly { + mstore(found, idx) + } } /// @notice compute the L2 alias of an L1 address