diff --git a/README.md b/README.md index 6cb2a60..3d90cd7 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,9 @@ By doing near mainnet testing, developers can quickly check sender authenticatio | Wormhole | ✅ | | | Stargate | ✅ | | | Across | ✅ | | +| CCIP | ✅ | ✅ | +| a.DI | ✅ | | +| Arbitrum (native) | ✅ | | ## Getting Started ### Installation @@ -65,6 +68,52 @@ 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. + +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. + +**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.` **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..29fc038 --- /dev/null +++ b/src/adi/AdiHelper.sol @@ -0,0 +1,126 @@ +// 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 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)) { + 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..3107096 --- /dev/null +++ b/src/arbitrum/ArbitrumNativeHelper.sol @@ -0,0 +1,168 @@ +// 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 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; + 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; + } + } + // 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 + 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/src/ccip/CcipHelper.sol b/src/ccip/CcipHelper.sol new file mode 100644 index 0000000..11b750c --- /dev/null +++ b/src/ccip/CcipHelper.sol @@ -0,0 +1,392 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +/// library imports +import "forge-std/Test.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); +} + +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 + /// @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) { + 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 + 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 + /// @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}); + } + 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/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/Adi.t.sol b/test/Adi.t.sol new file mode 100644 index 0000000..0b8da1d --- /dev/null +++ b/test/Adi.t.sol @@ -0,0 +1,180 @@ +// 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; + /// @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; + 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(); + + // 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, + dstCcipRouter: ETH_CCIP_ROUTER, + dstCcipChainSelector: ETH_CCIP_CHAIN_SELECTOR, + srcCcipOnRamp: address(0), + dstLzEndpoint: LZ_ENDPOINT_V2, + srcHlMailbox: ARB_HL_MAILBOX, + dstHlMailbox: ETH_HL_MAILBOX, + 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"); + } +} diff --git a/test/CCIP.t.sol b/test/CCIP.t.sol new file mode 100644 index 0000000..653d2ea --- /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 "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"; + +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); + } +}