From 9020f16faaba11d4e9542357be69777698499958 Mon Sep 17 00:00:00 2001 From: 0xTimepunk <45543880+0xTimepunk@users.noreply.github.com> Date: Tue, 5 May 2026 19:45:33 +0100 Subject: [PATCH 1/6] feat: add CCIP helper Adds a Foundry test helper for Chainlink CCIP that simulates cross-chain message and token delivery across forked mainnets, mirroring the HyperlaneHelper / AcrossV3Helper pattern. Detects both 1.5 CCIPSendRequested and 1.6 CCIPMessageSent events, resolves the destination OffRamp via Router.getOffRamps(), credits dest tokens via deal(), and pranks as the OffRamp to call routeMessage so ccipReceive runs under realistic onlyOffRamp semantics. Tests cover data-only, funds-only, funds + data, multi-destination, and fee estimates against real ETH<->ARB forks. --- .gitmodules | 3 + README.md | 18 ++ foundry.toml | 2 +- lib/chainlink-ccip | 1 + src/ccip/CcipHelper.sol | 379 ++++++++++++++++++++++++++++++++++++++++ test/CCIP.t.sol | 277 +++++++++++++++++++++++++++++ 6 files changed, 679 insertions(+), 1 deletion(-) create mode 160000 lib/chainlink-ccip create mode 100644 src/ccip/CcipHelper.sol create mode 100644 test/CCIP.t.sol diff --git a/.gitmodules b/.gitmodules index 9337666..287c8a4 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,3 +7,6 @@ [submodule "lib/openzeppelin-contracts"] path = lib/openzeppelin-contracts url = https://github.com/OpenZeppelin/openzeppelin-contracts +[submodule "lib/chainlink-ccip"] + path = lib/chainlink-ccip + url = https://github.com/smartcontractkit/chainlink-ccip diff --git a/README.md b/README.md index 6cb2a60..4f6aa56 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ By doing near mainnet testing, developers can quickly check sender authenticatio | Wormhole | ✅ | | | Stargate | ✅ | | | Across | ✅ | | +| CCIP | ✅ | ✅ | ## Getting Started ### Installation @@ -65,6 +66,23 @@ Vm.Log[] memory logs = vm.getRecordedLogs(); hyperlaneHelper.helpWithEstimates(L1_Mailbox, L2_HLMailbox, L2_FORK_ID, logs); ``` +CCIP (Chainlink CCIP — supports both 1.5 `CCIPSendRequested` and 1.6 `CCIPMessageSent` events): + +```solidity +vm.recordLogs(); +_yourCcipSendOnSourceChain(DST_CHAIN_SELECTOR, address(target), data); +Vm.Log[] memory logs = vm.getRecordedLogs(); +ccipHelper.help(CcipHelper.HelpArgs({ + dstForkId: DST_FORK_ID, + dstRouter: DST_ROUTER, + expDstChainSelector: DST_CHAIN_SELECTOR, + srcOnRamp: address(0), // optional emitter filter; 0 = accept any OnRamp + logs: logs +})); +``` + +`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. + 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/foundry.toml b/foundry.toml index 40ed1f7..6d5f21d 100644 --- a/foundry.toml +++ b/foundry.toml @@ -6,4 +6,4 @@ solc_version = "0.8.28" # Override for the solc version (setting this ignores `auto_detect_solc`) evm_version = "cancun" via_ir = true -remappings = ["ds-test/=lib/forge-std/lib/ds-test/src/","forge-std/=lib/forge-std/src/","solady/=lib/solady/"] +remappings = ["ds-test/=lib/forge-std/lib/ds-test/src/","forge-std/=lib/forge-std/src/","solady/=lib/solady/","@chainlink-ccip/=lib/chainlink-ccip/chains/evm/"] diff --git a/lib/chainlink-ccip b/lib/chainlink-ccip new file mode 160000 index 0000000..e9476e9 --- /dev/null +++ b/lib/chainlink-ccip @@ -0,0 +1 @@ +Subproject commit e9476e9948c5f84fd4e980484a520085bcc1b9d5 diff --git a/src/ccip/CcipHelper.sol b/src/ccip/CcipHelper.sol new file mode 100644 index 0000000..1a77bf3 --- /dev/null +++ b/src/ccip/CcipHelper.sol @@ -0,0 +1,379 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +/// library imports +import "forge-std/Test.sol"; + +/// chainlink imports +import {Client} from "@chainlink-ccip/contracts/libraries/Client.sol"; +import {Internal} from "@chainlink-ccip/contracts/libraries/Internal.sol"; +import {IRouter} from "@chainlink-ccip/contracts/interfaces/IRouter.sol"; +import {IRouterClient} from "@chainlink-ccip/contracts/interfaces/IRouterClient.sol"; +import {IAny2EVMMessageReceiver} from "@chainlink-ccip/contracts/interfaces/IAny2EVMMessageReceiver.sol"; + +interface IERC165 { + function supportsInterface(bytes4 interfaceId) external view returns (bool); +} + +interface IERC20Like { + function balanceOf(address account) external view returns (uint256); +} + +/// @notice Mirror of the `Router.OffRamp` struct + `getOffRamps()` view from the concrete `Router.sol`. Defined locally +/// to avoid pulling Router.sol's transitive shared-contracts dependency. +interface IRouterWithOffRamps { + struct OffRamp { + uint64 sourceChainSelector; + address offRamp; + } + + function getOffRamps() external view returns (OffRamp[] memory); +} + + +/// @title CcipHelper +/// @notice Helps simulate Chainlink CCIP message + token transfers across forked chains. +/// @dev Detects both CCIP 1.6 `OnRamp.CCIPMessageSent(uint64,uint64,Internal.EVM2AnyRampMessage)` and CCIP 1.5 +/// `EVM2EVMOnRamp.CCIPSendRequested(EVM2EVMMessage)` source-side events. For each match it switches to the destination +/// fork, resolves the OffRamp via `Router.getOffRamps()`, deals destination tokens to the receiver (1.6 only), and +/// pranks as the OffRamp to call `IRouter.routeMessage` so the receiver's `ccipReceive` runs under realistic +/// `onlyOffRamp` semantics. +/// @dev This helper exercises the receiver callback only. It does NOT exercise the real `OffRamp.executeSingleMessage` +/// path, so `TokenPool.releaseOrMint`, rate limits, RMN curse checks, and CCTP attestations are NOT simulated. Tokens +/// are credited to the receiver via `StdCheats.deal`. +/// @dev For CCIP 1.5 token transfers the 1.5 message carries only the SOURCE token address. The helper resolves the +/// destination token on the dest fork via `EVM2EVMOffRamp.getPoolBySourceToken(srcToken).getToken()` and `deal`s it. +contract CcipHelper is Test { + /// @dev keccak256 of the CCIP 1.6 `OnRamp.CCIPMessageSent` event signature. + bytes32 public constant CCIP_MESSAGE_SENT_SELECTOR = + 0x192442a2b2adb6a7948f097023cb6b57d29d3a7a5dd33e6666d33c39cc456f32; + + /// @dev keccak256 of the CCIP 1.5 `EVM2EVMOnRamp.CCIPSendRequested` event signature. + /// signature: CCIPSendRequested((uint64,address,address,uint64,uint256,bool,uint64,address,uint256,bytes,(address,uint256)[],bytes[],bytes32)) + bytes32 public constant CCIP_SEND_REQUESTED_SELECTOR = + 0xd0c3c799bf9e2639de44391e7f524d229b2b55f5b1ea94b2bf7da42f7243dddd; + + /// @dev Vendored CCIP 1.5 `Internal.EVM2EVMMessage` struct. Layout matches the legacy + /// `smartcontractkit/ccip` repo at v1.5.x. Used only for log decoding. + struct EVM2EVMMessage { + uint64 sourceChainSelector; + address sender; + address receiver; + uint64 sequenceNumber; + uint256 gasLimit; + bool strict; + uint64 nonce; + address feeToken; + uint256 feeTokenAmount; + bytes data; + Client.EVMTokenAmount[] tokenAmounts; + bytes[] sourceTokenData; + bytes32 messageId; + } + + /// @dev Vendored CCIP 1.5 `Internal.SourceTokenData` struct. Encoded by the OnRamp into + /// `EVM2EVMMessage.sourceTokenData[i]` so the destination token address is recoverable on the dest fork without + /// any registry lookup. + struct SourceTokenData { + bytes sourcePoolAddress; + bytes destTokenAddress; + bytes extraData; + uint32 destGasAmount; + } + + /// @dev Standard `gasForCallExactCheck` value used by mainnet OffRamps. + uint16 public constant GAS_FOR_CALL_EXACT_CHECK = 5_000; + + /// @dev CCIP's empty-extraArgs default gas limit (matches OffRamp + chainlink-local). + uint256 public constant DEFAULT_GAS_LIMIT = 200_000; + + struct HelpArgs { + uint256 dstForkId; // destination fork id created via vm.createSelectFork + address dstRouter; // destination chain Router (Router 1.2.0) + uint64 expDstChainSelector; // expected destination chain selector; 0 ⇒ accept any + address srcOnRamp; // expected emitter of CCIPMessageSent; address(0) ⇒ accept any + Vm.Log[] logs; // logs captured via vm.recordLogs / vm.getRecordedLogs + } + + error NoOffRampsRegistered(address router); + error NoOffRampForSource(uint64 sourceChainSelector); + error InvalidExtraArgsTag(bytes4 tag); + error TooManyTokens(uint256 count); + error ReceiverCallFailed(bytes returnData); + error V15SourceTokenDataMalformed(); + + mapping(bytes32 => bool) internal _processedMessageIds; + + ////////////////////////////////////////////////////////////// + // EXTERNAL FUNCTIONS // + ////////////////////////////////////////////////////////////// + + /// @notice helps relay CCIP messages to a single destination + /// @param args the relay arguments + function help(HelpArgs memory args) external { + _help(args, false); + } + + /// @notice helps relay CCIP messages to multiple destinations + /// @param argsArray array of per-destination relay arguments + function help(HelpArgs[] memory argsArray) external { + for (uint256 i; i < argsArray.length; ++i) { + _help(argsArray[i], false); + } + } + + /// @notice relays CCIP messages and emits the source-side fee paid for each delivered message + /// @param args the relay arguments + /// @dev emits `ccipFeePaid` (uint256), `ccipFeeToken` (address), `ccipFeeValueJuels` (uint256) per message. + function helpWithEstimates(HelpArgs memory args) external { + _help(args, true); + } + + /// @notice filter logs to those matching either `CCIPMessageSent` (1.6) or `CCIPSendRequested` (1.5) + /// @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; + bytes32 sel = logs[i].topics[0]; + if (sel == CCIP_MESSAGE_SENT_SELECTOR || sel == CCIP_SEND_REQUESTED_SELECTOR) { + found[idx++] = logs[i]; + if (idx == length) break; + } + } + } + + /// @notice filter logs by an explicit selector + /// @param logs the recorded logs + /// @param selector the event selector to match on `topics[0]` + /// @param length the expected number of matching logs + /// @return found array of matching logs + function findLogs(Vm.Log[] calldata logs, bytes32 selector, uint256 length) + external + pure + returns (Vm.Log[] memory found) + { + return _findLogs(logs, selector, length); + } + + ////////////////////////////////////////////////////////////// + // INTERNAL FUNCTIONS // + ////////////////////////////////////////////////////////////// + + /// @notice scans logs, decodes the matching CCIP message, and routes each match to the destination fork + function _help(HelpArgs memory args, bool emitEstimates) internal { + uint256 prevForkId = vm.activeFork(); + + for (uint256 i; i < args.logs.length; ++i) { + Vm.Log memory l = args.logs[i]; + if (l.topics.length == 0) continue; + if (args.srcOnRamp != address(0) && l.emitter != args.srcOnRamp) continue; + + bytes32 sel = l.topics[0]; + if (sel == CCIP_MESSAGE_SENT_SELECTOR) { + _processV16Log(l, args, emitEstimates); + } else if (sel == CCIP_SEND_REQUESTED_SELECTOR) { + _processV15Log(l, args, emitEstimates); + } + } + + vm.selectFork(prevForkId); + } + + /// @notice handle a CCIP 1.6 `CCIPMessageSent` log + function _processV16Log(Vm.Log memory l, HelpArgs memory args, bool emitEstimates) internal { + if (l.topics.length < 3) return; + + uint64 destChainSelector = uint64(uint256(l.topics[1])); + if (args.expDstChainSelector != 0 && destChainSelector != args.expDstChainSelector) return; + + Internal.EVM2AnyRampMessage memory m = abi.decode(l.data, (Internal.EVM2AnyRampMessage)); + + if (_processedMessageIds[m.header.messageId]) return; + _processedMessageIds[m.header.messageId] = true; + + if (emitEstimates) { + emit log_named_uint("ccipFeePaid", m.feeTokenAmount); + emit log_named_address("ccipFeeToken", m.feeToken); + emit log_named_uint("ccipFeeValueJuels", m.feeValueJuels); + } + + Client.GenericExtraArgsV2 memory extra = _decodeExtraArgs(m.extraArgs); + + vm.selectFork(args.dstForkId); + _routeV16(args.dstRouter, m, extra); + } + + /// @notice handle a CCIP 1.5 `CCIPSendRequested` log + function _processV15Log(Vm.Log memory l, HelpArgs memory args, bool emitEstimates) internal { + EVM2EVMMessage memory m = abi.decode(l.data, (EVM2EVMMessage)); + + if (m.tokenAmounts.length > 1) revert TooManyTokens(m.tokenAmounts.length); + + if (_processedMessageIds[m.messageId]) return; + _processedMessageIds[m.messageId] = true; + + if (emitEstimates) { + emit log_named_uint("ccipFeePaid", m.feeTokenAmount); + emit log_named_address("ccipFeeToken", m.feeToken); + } + + vm.selectFork(args.dstForkId); + _routeV15(args.dstRouter, m); + } + + /// @notice CCIP 1.6 destination-fork routing + function _routeV16( + address dstRouter, + Internal.EVM2AnyRampMessage memory m, + Client.GenericExtraArgsV2 memory extra + ) internal { + if (m.tokenAmounts.length > 1) revert TooManyTokens(m.tokenAmounts.length); + + address receiver = abi.decode(m.receiver, (address)); + + Client.EVMTokenAmount[] memory destTokenAmounts = new Client.EVMTokenAmount[](m.tokenAmounts.length); + if (m.tokenAmounts.length == 1) { + address destToken = address(uint160(bytes20(m.tokenAmounts[0].destTokenAddress))); + uint256 amount = m.tokenAmounts[0].amount; + destTokenAmounts[0] = Client.EVMTokenAmount({token: destToken, amount: amount}); + uint256 prior = IERC20Like(destToken).balanceOf(receiver); + deal(destToken, receiver, prior + amount); + } + + // Mirror OffRamp's pre-call gate: skip if data+gasLimit empty, or receiver is EOA, or no ERC-165 support. + bool hasData = m.data.length > 0 || extra.gasLimit > 0; + bool hasCode = receiver.code.length > 0; + bool isReceiver = hasCode && _supportsReceiverInterface(receiver); + if (!hasData || !hasCode || !isReceiver) return; + + Client.Any2EVMMessage memory anyMsg = Client.Any2EVMMessage({ + messageId: m.header.messageId, + sourceChainSelector: m.header.sourceChainSelector, + sender: abi.encode(m.sender), + data: m.data, + destTokenAmounts: destTokenAmounts + }); + + address offRamp = _resolveOffRamp(dstRouter, m.header.sourceChainSelector); + + vm.prank(offRamp); + (bool success, bytes memory retData,) = + IRouter(dstRouter).routeMessage(anyMsg, GAS_FOR_CALL_EXACT_CHECK, extra.gasLimit, receiver); + if (!success) revert ReceiverCallFailed(retData); + } + + /// @notice CCIP 1.5 destination-fork routing + /// @dev Destination token is decoded from `EVM2EVMMessage.sourceTokenData[i]` which the 1.5 OnRamp populates from + /// the source pool's `lockOrBurn` return (`destTokenAddress`). No OffRamp/registry lookup needed. + function _routeV15(address dstRouter, EVM2EVMMessage memory m) internal { + Client.EVMTokenAmount[] memory destTokenAmounts = new Client.EVMTokenAmount[](m.tokenAmounts.length); + if (m.tokenAmounts.length == 1) { + address destToken = _decodeV15DestToken(m.sourceTokenData[0]); + uint256 amount = m.tokenAmounts[0].amount; + destTokenAmounts[0] = Client.EVMTokenAmount({token: destToken, amount: amount}); + uint256 prior = IERC20Like(destToken).balanceOf(m.receiver); + deal(destToken, m.receiver, prior + amount); + } + + Client.Any2EVMMessage memory anyMsg = Client.Any2EVMMessage({ + messageId: m.messageId, + sourceChainSelector: m.sourceChainSelector, + sender: abi.encode(m.sender), + data: m.data, + destTokenAmounts: destTokenAmounts + }); + + bool hasData = m.data.length > 0 || m.gasLimit > 0; + bool hasCode = m.receiver.code.length > 0; + bool isReceiver = hasCode && _supportsReceiverInterface(m.receiver); + if (!hasData || !hasCode || !isReceiver) return; + + address offRamp = _resolveOffRamp(dstRouter, m.sourceChainSelector); + + vm.prank(offRamp); + (bool success, bytes memory retData,) = + IRouter(dstRouter).routeMessage(anyMsg, GAS_FOR_CALL_EXACT_CHECK, m.gasLimit, m.receiver); + if (!success) revert ReceiverCallFailed(retData); + } + + /// @notice decode CCIP 1.5 sourceTokenData[i] = abi.encode(SourceTokenData) and extract the dest token address + function _decodeV15DestToken(bytes memory sourceTokenDataBytes) internal pure returns (address) { + SourceTokenData memory s = abi.decode(sourceTokenDataBytes, (SourceTokenData)); + bytes memory destAddr = s.destTokenAddress; + if (destAddr.length != 32 && destAddr.length != 20) revert V15SourceTokenDataMalformed(); + return abi.decode(destAddr, (address)); + } + + /// @notice resolve newest OffRamp registered for sourceChainSelector via backwards iteration + function _resolveOffRamp(address dstRouter, uint64 sourceChainSelector) internal view returns (address) { + IRouterWithOffRamps.OffRamp[] memory offRamps = IRouterWithOffRamps(dstRouter).getOffRamps(); + if (offRamps.length == 0) revert NoOffRampsRegistered(dstRouter); + for (uint256 i = offRamps.length; i > 0; --i) { + if (offRamps[i - 1].sourceChainSelector == sourceChainSelector) { + return offRamps[i - 1].offRamp; + } + } + revert NoOffRampForSource(sourceChainSelector); + } + + /// @notice decode CCIP `extraArgs` handling V2 tag, V1 tag, and empty-default + function _decodeExtraArgs(bytes memory extraArgs) internal pure returns (Client.GenericExtraArgsV2 memory) { + if (extraArgs.length == 0) { + return Client.GenericExtraArgsV2({gasLimit: DEFAULT_GAS_LIMIT, allowOutOfOrderExecution: false}); + } + bytes4 tag = bytes4(extraArgs); + bytes memory body = _slice(extraArgs, 4); + + if (tag == Client.GENERIC_EXTRA_ARGS_V2_TAG) { + return abi.decode(body, (Client.GenericExtraArgsV2)); + } + if (tag == Client.EVM_EXTRA_ARGS_V1_TAG) { + uint256 g = abi.decode(body, (uint256)); + return Client.GenericExtraArgsV2({gasLimit: g, allowOutOfOrderExecution: false}); + } + revert InvalidExtraArgsTag(tag); + } + + /// @notice ERC-165 check for IAny2EVMMessageReceiver. Wrapped in low-level call to swallow unsupported reverts. + function _supportsReceiverInterface(address receiver) internal view returns (bool) { + bytes4 iid = type(IAny2EVMMessageReceiver).interfaceId; + (bool ok, bytes memory ret) = + receiver.staticcall(abi.encodeWithSelector(IERC165.supportsInterface.selector, iid)); + return ok && ret.length >= 32 && abi.decode(ret, (bool)); + } + + /// @notice slice a memory bytes array starting at `start` to the end + function _slice(bytes memory data, uint256 start) internal pure returns (bytes memory out) { + require(start <= data.length, "CcipHelper: slice oob"); + uint256 len = data.length - start; + out = new bytes(len); + for (uint256 i; i < len; ++i) { + out[i] = data[start + i]; + } + } + + /// @notice find logs with a specific selector + function _findLogs(Vm.Log[] memory logs, bytes32 selector, uint256 length) + internal + 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] == selector) { + found[idx++] = logs[i]; + if (idx == length) break; + } + } + } +} diff --git a/test/CCIP.t.sol b/test/CCIP.t.sol new file mode 100644 index 0000000..cae747c --- /dev/null +++ b/test/CCIP.t.sol @@ -0,0 +1,277 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +import "forge-std/Test.sol"; + +import {Client} from "@chainlink-ccip/contracts/libraries/Client.sol"; +import {IRouterClient} from "@chainlink-ccip/contracts/interfaces/IRouterClient.sol"; +import {IAny2EVMMessageReceiver} from "@chainlink-ccip/contracts/interfaces/IAny2EVMMessageReceiver.sol"; + +import {CcipHelper} from "src/ccip/CcipHelper.sol"; + +interface IERC20 { + function balanceOf(address) external view returns (uint256); + function approve(address, uint256) external returns (bool); +} + +interface IERC165 { + function supportsInterface(bytes4) external view returns (bool); +} + +contract Target is IAny2EVMMessageReceiver, IERC165 { + bytes32 public lastMessageId; + uint64 public lastSourceChainSelector; + address public lastSender; + bytes public lastData; + address public lastToken; + uint256 public lastAmount; + uint256 public callCount; + + function ccipReceive(Client.Any2EVMMessage calldata m) external override { + lastMessageId = m.messageId; + lastSourceChainSelector = m.sourceChainSelector; + lastSender = abi.decode(m.sender, (address)); + lastData = m.data; + if (m.destTokenAmounts.length == 1) { + lastToken = m.destTokenAmounts[0].token; + lastAmount = m.destTokenAmounts[0].amount; + } + callCount += 1; + } + + function supportsInterface(bytes4 id) external pure override returns (bool) { + return id == type(IAny2EVMMessageReceiver).interfaceId || id == type(IERC165).interfaceId; + } +} + +contract CcipHelperTest is Test { + CcipHelper ccipHelper; + Target target; + Target altTarget; + + uint256 ETH_FORK_ID; + uint256 ARB_FORK_ID; + + uint64 constant ETH_CHAIN_SELECTOR = 5009297550715157269; + uint64 constant ARB_CHAIN_SELECTOR = 4949039107694359620; + + address constant ETH_ROUTER = 0x80226fc0Ee2b096224EeAc085Bb9a8cba1146f7D; + address constant ARB_ROUTER = 0x141fa059441E0ca23ce184B6A78bafD2A517DdE8; + + address constant LINK_ETH = 0x514910771AF9Ca656af840dff83E8264EcF986CA; + address constant USDC_ETH = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address constant USDC_ARB = 0xaf88d065e77c8cC2239327C5EDb3A432268e5831; + + string RPC_ETH = vm.envString("ETH_MAINNET_RPC_URL"); + string RPC_ARB = vm.envString("ARBITRUM_MAINNET_RPC_URL"); + + function setUp() external { + // Pinned to recent post-CCIP-1.6 cutover blocks. Adjust if RPC pruning rejects the height. + ETH_FORK_ID = vm.createSelectFork(RPC_ETH, 23_000_000); + ccipHelper = new CcipHelper(); + + ARB_FORK_ID = vm.createSelectFork(RPC_ARB, 380_000_000); + target = new Target(); + altTarget = new Target(); + } + + function testSimpleCCIP() external { + vm.selectFork(ETH_FORK_ID); + + vm.recordLogs(); + // || + // || + // \/ This is the part of the code you could copy to use the CcipHelper + // in your own tests. + _ccipSendDataOnly(ARB_CHAIN_SELECTOR, address(target), abi.encode(uint256(42))); + Vm.Log[] memory logs = vm.getRecordedLogs(); + ccipHelper.help( + CcipHelper.HelpArgs({ + dstForkId: ARB_FORK_ID, + dstRouter: ARB_ROUTER, + expDstChainSelector: ARB_CHAIN_SELECTOR, + srcOnRamp: address(0), + logs: logs + }) + ); + // /\ + // || + // || + + vm.selectFork(ARB_FORK_ID); + assertEq(target.callCount(), 1, "Target.ccipReceive not called"); + assertEq(target.lastSourceChainSelector(), ETH_CHAIN_SELECTOR, "Source selector mismatch"); + assertEq(target.lastSender(), address(this), "Sender mismatch"); + assertEq(abi.decode(target.lastData(), (uint256)), 42, "Data mismatch"); + } + + function testMultiDstCCIP() external { + vm.selectFork(ETH_FORK_ID); + + vm.recordLogs(); + _ccipSendDataOnly(ARB_CHAIN_SELECTOR, address(target), abi.encode(uint256(1))); + _ccipSendDataOnly(ARB_CHAIN_SELECTOR, address(altTarget), abi.encode(uint256(2))); + Vm.Log[] memory logs = vm.getRecordedLogs(); + + ccipHelper.help( + CcipHelper.HelpArgs({ + dstForkId: ARB_FORK_ID, + dstRouter: ARB_ROUTER, + expDstChainSelector: ARB_CHAIN_SELECTOR, + srcOnRamp: address(0), + logs: logs + }) + ); + + vm.selectFork(ARB_FORK_ID); + assertEq(target.callCount(), 1); + assertEq(altTarget.callCount(), 1); + assertEq(abi.decode(target.lastData(), (uint256)), 1); + assertEq(abi.decode(altTarget.lastData(), (uint256)), 2); + } + + function testCCIPWithEstimates() external { + vm.selectFork(ETH_FORK_ID); + + vm.recordLogs(); + _ccipSendDataOnly(ARB_CHAIN_SELECTOR, address(target), abi.encode(uint256(7))); + Vm.Log[] memory logs = vm.getRecordedLogs(); + + ccipHelper.helpWithEstimates( + CcipHelper.HelpArgs({ + dstForkId: ARB_FORK_ID, + dstRouter: ARB_ROUTER, + expDstChainSelector: ARB_CHAIN_SELECTOR, + srcOnRamp: address(0), + logs: logs + }) + ); + + vm.selectFork(ARB_FORK_ID); + assertEq(target.callCount(), 1); + assertEq(abi.decode(target.lastData(), (uint256)), 7); + } + + function testCCIPFundsOnly() external { + vm.selectFork(ETH_FORK_ID); + + uint256 amount = 100_000_000; // 100 USDC (6 decimals) + + vm.recordLogs(); + _ccipSendWithToken(ARB_CHAIN_SELECTOR, address(target), USDC_ETH, amount, bytes("")); + Vm.Log[] memory logs = vm.getRecordedLogs(); + + ccipHelper.help( + CcipHelper.HelpArgs({ + dstForkId: ARB_FORK_ID, + dstRouter: ARB_ROUTER, + expDstChainSelector: ARB_CHAIN_SELECTOR, + srcOnRamp: address(0), + logs: logs + }) + ); + + vm.selectFork(ARB_FORK_ID); + // funds-only with empty data still goes through routeMessage because gasLimit > 0 in extraArgs; + // ccipReceive runs and observes the credited destination tokens. + assertEq(target.callCount(), 1, "Target.ccipReceive not called"); + assertEq(target.lastToken(), USDC_ARB, "destination token mismatch"); + assertEq(target.lastAmount(), amount, "destination amount mismatch"); + assertEq(IERC20(USDC_ARB).balanceOf(address(target)), amount, "target USDC balance mismatch"); + assertEq(target.lastData().length, 0, "expected empty data"); + } + + function testCCIPFundsAndData() external { + vm.selectFork(ETH_FORK_ID); + + uint256 amount = 50_000_000; // 50 USDC + bytes memory payload = abi.encode("hello", uint256(7)); + + vm.recordLogs(); + _ccipSendWithToken(ARB_CHAIN_SELECTOR, address(target), USDC_ETH, amount, payload); + Vm.Log[] memory logs = vm.getRecordedLogs(); + + ccipHelper.help( + CcipHelper.HelpArgs({ + dstForkId: ARB_FORK_ID, + dstRouter: ARB_ROUTER, + expDstChainSelector: ARB_CHAIN_SELECTOR, + srcOnRamp: address(0), + logs: logs + }) + ); + + vm.selectFork(ARB_FORK_ID); + assertEq(target.callCount(), 1); + assertEq(target.lastToken(), USDC_ARB); + assertEq(target.lastAmount(), amount); + assertEq(IERC20(USDC_ARB).balanceOf(address(target)), amount); + assertEq(target.lastData(), payload, "data mismatch"); + assertEq(target.lastSender(), address(this)); + } + + function testFindLogsFiltersCCIPMessageSent() external { + vm.selectFork(ETH_FORK_ID); + + vm.recordLogs(); + _ccipSendDataOnly(ARB_CHAIN_SELECTOR, address(target), abi.encode(uint256(99))); + Vm.Log[] memory logs = vm.getRecordedLogs(); + + Vm.Log[] memory ccipLogs = ccipHelper.findLogs(logs, 1); + assertEq(ccipLogs.length, 1); + bytes32 sel = ccipLogs[0].topics[0]; + bool isCcipEvent = + sel == ccipHelper.CCIP_MESSAGE_SENT_SELECTOR() || sel == ccipHelper.CCIP_SEND_REQUESTED_SELECTOR(); + assertTrue(isCcipEvent, "matched log is not a CCIP event"); + } + + /// @dev Sends a data-only CCIP message paying the fee in LINK. + function _ccipSendDataOnly(uint64 dstSelector, address receiver, bytes memory data) internal { + Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({ + receiver: abi.encode(receiver), + data: data, + tokenAmounts: new Client.EVMTokenAmount[](0), + feeToken: LINK_ETH, + extraArgs: Client._argsToBytes( + Client.GenericExtraArgsV2({gasLimit: 200_000, allowOutOfOrderExecution: true}) + ) + }); + + uint256 fee = IRouterClient(ETH_ROUTER).getFee(dstSelector, message); + deal(LINK_ETH, address(this), fee); + IERC20(LINK_ETH).approve(ETH_ROUTER, fee); + + IRouterClient(ETH_ROUTER).ccipSend(dstSelector, message); + } + + /// @dev Sends a CCIP message carrying 1 token (and optional data) paying the fee in LINK. + function _ccipSendWithToken( + uint64 dstSelector, + address receiver, + address token, + uint256 amount, + bytes memory data + ) internal { + Client.EVMTokenAmount[] memory tokenAmounts = new Client.EVMTokenAmount[](1); + tokenAmounts[0] = Client.EVMTokenAmount({token: token, amount: amount}); + + Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({ + receiver: abi.encode(receiver), + data: data, + tokenAmounts: tokenAmounts, + feeToken: LINK_ETH, + extraArgs: Client._argsToBytes( + Client.GenericExtraArgsV2({gasLimit: 500_000, allowOutOfOrderExecution: true}) + ) + }); + + uint256 fee = IRouterClient(ETH_ROUTER).getFee(dstSelector, message); + deal(LINK_ETH, address(this), fee); + IERC20(LINK_ETH).approve(ETH_ROUTER, fee); + + deal(token, address(this), amount); + IERC20(token).approve(ETH_ROUTER, amount); + + IRouterClient(ETH_ROUTER).ccipSend(dstSelector, message); + } +} From 9894ea34ee151a318ad2f366ddca182d9d94908f Mon Sep 17 00:00:00 2001 From: 0xTimepunk <45543880+0xTimepunk@users.noreply.github.com> Date: Wed, 6 May 2026 09:27:55 +0100 Subject: [PATCH 2/6] refactor: vendor CCIP interfaces, drop chainlink-ccip submodule Mirrors the src/across pattern: only the structs and interfaces the helper actually uses are kept under src/ccip/interfaces/ (Client, Internal, IRouter, IRouterClient, IAny2EVMMessageReceiver). Drops the lib/chainlink-ccip submodule and the @chainlink-ccip/ remapping so the helper is self-contained, the repo clones faster, and CCIP support follows the same conventions as every other helper. --- .gitmodules | 3 - foundry.toml | 2 +- lib/chainlink-ccip | 1 - src/ccip/CcipHelper.sol | 11 ++-- src/ccip/interfaces/Client.sol | 51 +++++++++++++++++ .../interfaces/IAny2EVMMessageReceiver.sol | 11 ++++ src/ccip/interfaces/IRouter.sol | 24 ++++++++ src/ccip/interfaces/IRouterClient.sol | 24 ++++++++ src/ccip/interfaces/Internal.sol | 55 +++++++++++++++++++ test/CCIP.t.sol | 6 +- 10 files changed, 174 insertions(+), 14 deletions(-) delete mode 160000 lib/chainlink-ccip create mode 100644 src/ccip/interfaces/Client.sol create mode 100644 src/ccip/interfaces/IAny2EVMMessageReceiver.sol create mode 100644 src/ccip/interfaces/IRouter.sol create mode 100644 src/ccip/interfaces/IRouterClient.sol create mode 100644 src/ccip/interfaces/Internal.sol diff --git a/.gitmodules b/.gitmodules index 287c8a4..9337666 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,6 +7,3 @@ [submodule "lib/openzeppelin-contracts"] path = lib/openzeppelin-contracts url = https://github.com/OpenZeppelin/openzeppelin-contracts -[submodule "lib/chainlink-ccip"] - path = lib/chainlink-ccip - url = https://github.com/smartcontractkit/chainlink-ccip diff --git a/foundry.toml b/foundry.toml index 6d5f21d..40ed1f7 100644 --- a/foundry.toml +++ b/foundry.toml @@ -6,4 +6,4 @@ solc_version = "0.8.28" # Override for the solc version (setting this ignores `auto_detect_solc`) evm_version = "cancun" via_ir = true -remappings = ["ds-test/=lib/forge-std/lib/ds-test/src/","forge-std/=lib/forge-std/src/","solady/=lib/solady/","@chainlink-ccip/=lib/chainlink-ccip/chains/evm/"] +remappings = ["ds-test/=lib/forge-std/lib/ds-test/src/","forge-std/=lib/forge-std/src/","solady/=lib/solady/"] diff --git a/lib/chainlink-ccip b/lib/chainlink-ccip deleted file mode 160000 index e9476e9..0000000 --- a/lib/chainlink-ccip +++ /dev/null @@ -1 +0,0 @@ -Subproject commit e9476e9948c5f84fd4e980484a520085bcc1b9d5 diff --git a/src/ccip/CcipHelper.sol b/src/ccip/CcipHelper.sol index 1a77bf3..e56156f 100644 --- a/src/ccip/CcipHelper.sol +++ b/src/ccip/CcipHelper.sol @@ -4,12 +4,11 @@ pragma solidity >=0.8.0; /// library imports import "forge-std/Test.sol"; -/// chainlink imports -import {Client} from "@chainlink-ccip/contracts/libraries/Client.sol"; -import {Internal} from "@chainlink-ccip/contracts/libraries/Internal.sol"; -import {IRouter} from "@chainlink-ccip/contracts/interfaces/IRouter.sol"; -import {IRouterClient} from "@chainlink-ccip/contracts/interfaces/IRouterClient.sol"; -import {IAny2EVMMessageReceiver} from "@chainlink-ccip/contracts/interfaces/IAny2EVMMessageReceiver.sol"; +/// local imports +import {Client} from "./interfaces/Client.sol"; +import {Internal} from "./interfaces/Internal.sol"; +import {IRouter} from "./interfaces/IRouter.sol"; +import {IAny2EVMMessageReceiver} from "./interfaces/IAny2EVMMessageReceiver.sol"; interface IERC165 { function supportsInterface(bytes4 interfaceId) external view returns (bool); diff --git a/src/ccip/interfaces/Client.sol b/src/ccip/interfaces/Client.sol new file mode 100644 index 0000000..e16d336 --- /dev/null +++ b/src/ccip/interfaces/Client.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +/// @notice Vendored from `smartcontractkit/chainlink-ccip` at tag `contracts-ccip-v1.6.0` +/// (`chains/evm/contracts/libraries/Client.sol`). Trimmed to the EVM-only subset that pigeon's +/// CcipHelper and tests consume; SVM/Solana extras were dropped. +library Client { + struct EVMTokenAmount { + address token; + uint256 amount; + } + + struct Any2EVMMessage { + bytes32 messageId; + uint64 sourceChainSelector; + bytes sender; // abi.decode(sender, (address)) for EVM source chains + bytes data; + EVMTokenAmount[] destTokenAmounts; + } + + struct EVM2AnyMessage { + bytes receiver; // abi.encode(address) + bytes data; + EVMTokenAmount[] tokenAmounts; + address feeToken; // address(0) ⇒ msg.value (native) + bytes extraArgs; + } + + /// @dev Tag for legacy gas-limit-only extra args. + bytes4 public constant EVM_EXTRA_ARGS_V1_TAG = 0x97a657c9; + + struct EVMExtraArgsV1 { + uint256 gasLimit; + } + + function _argsToBytes(EVMExtraArgsV1 memory extraArgs) internal pure returns (bytes memory) { + return abi.encodeWithSelector(EVM_EXTRA_ARGS_V1_TAG, extraArgs); + } + + /// @dev Tag for current gas-limit + out-of-order extra args. + bytes4 public constant GENERIC_EXTRA_ARGS_V2_TAG = 0x181dcf10; + + struct GenericExtraArgsV2 { + uint256 gasLimit; + bool allowOutOfOrderExecution; + } + + function _argsToBytes(GenericExtraArgsV2 memory extraArgs) internal pure returns (bytes memory) { + return abi.encodeWithSelector(GENERIC_EXTRA_ARGS_V2_TAG, extraArgs); + } +} diff --git a/src/ccip/interfaces/IAny2EVMMessageReceiver.sol b/src/ccip/interfaces/IAny2EVMMessageReceiver.sol new file mode 100644 index 0000000..a7a46d4 --- /dev/null +++ b/src/ccip/interfaces/IAny2EVMMessageReceiver.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +import {Client} from "./Client.sol"; + +/// @notice Vendored from `smartcontractkit/chainlink-ccip` at tag `contracts-ccip-v1.6.0` +/// (`chains/evm/contracts/interfaces/IAny2EVMMessageReceiver.sol`). +interface IAny2EVMMessageReceiver { + /// @notice Called by the Router to deliver a CCIP message. + function ccipReceive(Client.Any2EVMMessage calldata message) external; +} diff --git a/src/ccip/interfaces/IRouter.sol b/src/ccip/interfaces/IRouter.sol new file mode 100644 index 0000000..ded4a50 --- /dev/null +++ b/src/ccip/interfaces/IRouter.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +import {Client} from "./Client.sol"; + +/// @notice Vendored from `smartcontractkit/chainlink-ccip` at tag `contracts-ccip-v1.6.0` +/// (`chains/evm/contracts/interfaces/IRouter.sol`). +interface IRouter { + error OnlyOffRamp(); + + /// @notice Route the message to its intended receiver contract. + function routeMessage( + Client.Any2EVMMessage calldata message, + uint16 gasForCallExactCheck, + uint256 gasLimit, + address receiver + ) external returns (bool success, bytes memory retBytes, uint256 gasUsed); + + /// @notice Returns the configured onRamp for a specific destination chain. + function getOnRamp(uint64 destChainSelector) external view returns (address onRampAddress); + + /// @notice Return true if the given offRamp is a configured offRamp for the given source chain. + function isOffRamp(uint64 sourceChainSelector, address offRamp) external view returns (bool); +} diff --git a/src/ccip/interfaces/IRouterClient.sol b/src/ccip/interfaces/IRouterClient.sol new file mode 100644 index 0000000..1d90ac8 --- /dev/null +++ b/src/ccip/interfaces/IRouterClient.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +import {Client} from "./Client.sol"; + +/// @notice Vendored from `smartcontractkit/chainlink-ccip` at tag `contracts-ccip-v1.6.0` +/// (`chains/evm/contracts/interfaces/IRouterClient.sol`). +interface IRouterClient { + error UnsupportedDestinationChain(uint64 destChainSelector); + error InsufficientFeeTokenAmount(); + error InvalidMsgValue(); + + function isChainSupported(uint64 destChainSelector) external view returns (bool); + + function getFee(uint64 destinationChainSelector, Client.EVM2AnyMessage memory message) + external + view + returns (uint256 fee); + + function ccipSend(uint64 destinationChainSelector, Client.EVM2AnyMessage calldata message) + external + payable + returns (bytes32); +} diff --git a/src/ccip/interfaces/Internal.sol b/src/ccip/interfaces/Internal.sol new file mode 100644 index 0000000..e23894f --- /dev/null +++ b/src/ccip/interfaces/Internal.sol @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +/// @notice Vendored from `smartcontractkit/chainlink-ccip` at tag `contracts-ccip-v1.6.0` +/// (`chains/evm/contracts/libraries/Internal.sol`). Only the structs decoded from CCIP 1.6 source-side +/// emissions and reconstructed for destination-side routing are kept; merkle / commit-report / OCR +/// helpers and chain-family selectors were dropped. +library Internal { + struct RampMessageHeader { + bytes32 messageId; + uint64 sourceChainSelector; + uint64 destChainSelector; + uint64 sequenceNumber; + uint64 nonce; + } + + struct EVM2AnyTokenTransfer { + address sourcePoolAddress; + bytes destTokenAddress; // ABI-encoded EVM address of the destination token + bytes extraData; + uint256 amount; + bytes destExecData; // abi.encode(uint32 destGasAmount) + } + + struct Any2EVMTokenTransfer { + bytes sourcePoolAddress; + address destTokenAddress; + uint32 destGasAmount; + bytes extraData; + uint256 amount; + } + + /// @notice Family-agnostic message routed to an OffRamp. + struct Any2EVMRampMessage { + RampMessageHeader header; + bytes sender; + bytes data; + address receiver; + uint256 gasLimit; + Any2EVMTokenTransfer[] tokenAmounts; + } + + /// @notice Family-agnostic message emitted from the OnRamp. + struct EVM2AnyRampMessage { + RampMessageHeader header; + address sender; + bytes data; + bytes receiver; + bytes extraArgs; + address feeToken; + uint256 feeTokenAmount; + uint256 feeValueJuels; + EVM2AnyTokenTransfer[] tokenAmounts; + } +} diff --git a/test/CCIP.t.sol b/test/CCIP.t.sol index cae747c..653d2ea 100644 --- a/test/CCIP.t.sol +++ b/test/CCIP.t.sol @@ -3,9 +3,9 @@ pragma solidity >=0.8.0; import "forge-std/Test.sol"; -import {Client} from "@chainlink-ccip/contracts/libraries/Client.sol"; -import {IRouterClient} from "@chainlink-ccip/contracts/interfaces/IRouterClient.sol"; -import {IAny2EVMMessageReceiver} from "@chainlink-ccip/contracts/interfaces/IAny2EVMMessageReceiver.sol"; +import {Client} from "src/ccip/interfaces/Client.sol"; +import {IRouterClient} from "src/ccip/interfaces/IRouterClient.sol"; +import {IAny2EVMMessageReceiver} from "src/ccip/interfaces/IAny2EVMMessageReceiver.sol"; import {CcipHelper} from "src/ccip/CcipHelper.sol"; 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 3/6] 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 4/6] 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 a6a2de070c24750e64d8936b2ea44e5f690c0e69 Mon Sep 17 00:00:00 2001 From: 0xTimepunk <45543880+0xTimepunk@users.noreply.github.com> Date: Fri, 8 May 2026 15:15:46 +0100 Subject: [PATCH 5/6] fix(ccip): handle 20-byte destTokenAddress, doc out-of-order caveat --- src/ccip/CcipHelper.sol | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/ccip/CcipHelper.sol b/src/ccip/CcipHelper.sol index e56156f..11b750c 100644 --- a/src/ccip/CcipHelper.sol +++ b/src/ccip/CcipHelper.sol @@ -304,11 +304,22 @@ contract CcipHelper is Test { } /// @notice decode CCIP 1.5 sourceTokenData[i] = abi.encode(SourceTokenData) and extract the dest token address + /// @dev `destTokenAddress` is encoded by source pools as either a 32-byte abi-encoded address or a raw 20-byte + /// address. abi.decode only accepts the 32-byte form, so the 20-byte form is read directly via assembly. function _decodeV15DestToken(bytes memory sourceTokenDataBytes) internal pure returns (address) { SourceTokenData memory s = abi.decode(sourceTokenDataBytes, (SourceTokenData)); bytes memory destAddr = s.destTokenAddress; - if (destAddr.length != 32 && destAddr.length != 20) revert V15SourceTokenDataMalformed(); - return abi.decode(destAddr, (address)); + if (destAddr.length == 32) { + return abi.decode(destAddr, (address)); + } + if (destAddr.length == 20) { + address result; + assembly { + result := shr(96, mload(add(destAddr, 32))) + } + return result; + } + revert V15SourceTokenDataMalformed(); } /// @notice resolve newest OffRamp registered for sourceChainSelector via backwards iteration @@ -324,6 +335,9 @@ contract CcipHelper is Test { } /// @notice decode CCIP `extraArgs` handling V2 tag, V1 tag, and empty-default + /// @dev The helper does NOT enforce CCIP's ordered execution semantics. Messages are delivered in log-emission + /// order regardless of `allowOutOfOrderExecution`. Protocols depending on strict message ordering should + /// test that separately. function _decodeExtraArgs(bytes memory extraArgs) internal pure returns (Client.GenericExtraArgsV2 memory) { if (extraArgs.length == 0) { return Client.GenericExtraArgsV2({gasLimit: DEFAULT_GAS_LIMIT, allowOutOfOrderExecution: false}); 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 6/6] 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