diff --git a/src/cctp/CctpV2Helper.sol b/src/cctp/CctpV2Helper.sol new file mode 100644 index 0000000..ccb7fdd --- /dev/null +++ b/src/cctp/CctpV2Helper.sol @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +/// library imports +import "forge-std/Test.sol"; +import {IMessageTransmitterV2} from "./interfaces/IMessageTransmitterV2.sol"; + +/// @title CCTP V2 Helper +/// @notice helps simulate CCTP V2 cross-chain USDC transfers by relaying MessageSent events +contract CctpV2Helper is Test { + /// @dev event selector for MessageSent(bytes) emitted by MessageTransmitterV2 + bytes32 constant MESSAGE_SENT_TOPIC = keccak256("MessageSent(bytes)"); + + /// @dev MessageTransmitterV2 address (same on all mainnet EVM chains via CREATE2) + address constant MESSAGE_TRANSMITTER_V2 = 0x81D40F21F12A8F0E3252Bccb954D722d4c464B64; + + /// @dev storage slot of the usedNonces mapping in MessageTransmitterV2 + uint256 constant USED_NONCES_SLOT = 29; + + /// @dev private key used to sign attestations in tests + uint256 public immutable TEST_ATTESTER_PK; + + /// @dev address derived from TEST_ATTESTER_PK + address public immutable testAttesterAddress; + + /// @dev tracks (sourceDomain, nonce) pairs already relayed to prevent double-mint on replay + mapping(bytes32 => bool) private _processedMessages; + + ////////////////////////////////////////////////////////////// + // CONSTRUCTOR // + ////////////////////////////////////////////////////////////// + + /// @notice creates a helper with a test attester private key + /// @param attesterPK the private key used to sign attestations (use 0 for default key 0x1) + constructor(uint256 attesterPK) { + uint256 pk = attesterPK == 0 ? 1 : attesterPK; + TEST_ATTESTER_PK = pk; + testAttesterAddress = vm.addr(pk); + } + + ////////////////////////////////////////////////////////////// + // EXTERNAL FUNCTIONS // + ////////////////////////////////////////////////////////////// + + /// @notice relays CCTP messages to a single destination domain + /// @param expectedDestDomain the CCTP domain ID to filter for + /// @param forkId the fork ID of the destination chain + /// @param logs the recorded logs from source chain execution + function help(uint32 expectedDestDomain, uint256 forkId, Vm.Log[] calldata logs) external { + _help(expectedDestDomain, forkId, logs, MESSAGE_TRANSMITTER_V2); + } + + /// @notice relays CCTP messages to a single destination domain, filtering by emitter + /// @param expectedDestDomain the CCTP domain ID to filter for + /// @param forkId the fork ID of the destination chain + /// @param logs the recorded logs from source chain execution + /// @param emitter only process MessageSent logs from this address + function help(uint32 expectedDestDomain, uint256 forkId, Vm.Log[] calldata logs, address emitter) external { + _help(expectedDestDomain, forkId, logs, emitter); + } + + /// @notice relays CCTP messages to multiple destination domains + /// @param expectedDestDomains array of CCTP domain IDs to filter for + /// @param forkIds array of fork IDs corresponding to each destination + /// @param logs the recorded logs from source chain execution + function help(uint32[] memory expectedDestDomains, uint256[] memory forkIds, Vm.Log[] calldata logs) external { + require(expectedDestDomains.length == forkIds.length, "CctpV2Helper: length mismatch"); + for (uint256 i; i < expectedDestDomains.length; ++i) { + _help(expectedDestDomains[i], forkIds[i], logs, MESSAGE_TRANSMITTER_V2); + } + } + + /// @notice relays CCTP messages to multiple destination domains, filtering by emitter + /// @param expectedDestDomains array of CCTP domain IDs to filter for + /// @param forkIds array of fork IDs corresponding to each destination + /// @param logs the recorded logs from source chain execution + /// @param emitter only process MessageSent logs from this address + function help( + uint32[] memory expectedDestDomains, + uint256[] memory forkIds, + Vm.Log[] calldata logs, + address emitter + ) external { + require(expectedDestDomains.length == forkIds.length, "CctpV2Helper: length mismatch"); + for (uint256 i; i < expectedDestDomains.length; ++i) { + _help(expectedDestDomains[i], forkIds[i], logs, emitter); + } + } + + ////////////////////////////////////////////////////////////// + // INTERNAL FUNCTIONS // + ////////////////////////////////////////////////////////////// + + /// @notice processes logs and relays matching CCTP messages to the destination fork + function _help(uint32 expectedDestDomain, uint256 forkId, Vm.Log[] memory logs, address emitter) internal { + uint256 prevForkId = vm.activeFork(); + + for (uint256 i; i < logs.length; i++) { + /// skip anonymous events / log0 (no topics) + if (logs[i].topics.length == 0) continue; + if (logs[i].topics[0] != MESSAGE_SENT_TOPIC) continue; + /// filter by emitter to avoid collisions with other MessageSent(bytes) events + if (logs[i].emitter != emitter) continue; + + bytes memory message = abi.decode(logs[i].data, (bytes)); + uint32 destDomain = _getDestinationDomain(message); + + if (destDomain != expectedDestDomain) continue; + + /// dedup: skip if this (sourceDomain, nonce) was already relayed + uint32 sourceDomain = _getSourceDomain(message); + bytes32 nonce = _getNonce(message); + bytes32 msgKey = keccak256(abi.encode(sourceDomain, nonce)); + if (_processedMessages[msgKey]) continue; + _processedMessages[msgKey] = true; + + /// switch to destination fork + vm.selectFork(forkId); + + /// replace production attesters with our test key + _setupTestAttester(); + + /// set finalityThresholdExecuted >= minFinalityThreshold (simulates attestation service) + _setFinalityExecuted(message); + + /// sign the modified message to create a valid attestation + bytes memory attestation = _signMessage(message); + + /// check if destinationCaller is set (restricts who can relay) + bytes32 destinationCaller = _getDestinationCaller(message); + + /// clear usedNonces for this message's nonce so relay succeeds on forked state + _clearUsedNonce(message); + + if (destinationCaller != bytes32(0)) { + vm.prank(address(uint160(uint256(destinationCaller)))); + } + + /// relay the message on destination + IMessageTransmitterV2(MESSAGE_TRANSMITTER_V2).receiveMessage(message, attestation); + + /// switch back to source fork + vm.selectFork(prevForkId); + } + } + + /// @notice replaces production attesters with the test attester on the destination chain + function _setupTestAttester() internal { + IMessageTransmitterV2 transmitter = IMessageTransmitterV2(MESSAGE_TRANSMITTER_V2); + address mgr = transmitter.attesterManager(); + + vm.startPrank(mgr); + /// enable our test attester + if (!transmitter.isEnabledAttester(testAttesterAddress)) { + transmitter.enableAttester(testAttesterAddress); + } + /// set threshold to 1 so only our signature is needed + transmitter.setSignatureThreshold(1); + vm.stopPrank(); + } + + /// @notice signs a CCTP message with the test attester key + /// @param message the raw CCTP message bytes + /// @return attestation the packed signature (r, s, v) + function _signMessage(bytes memory message) internal view returns (bytes memory attestation) { + bytes32 digest = keccak256(message); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(TEST_ATTESTER_PK, digest); + attestation = abi.encodePacked(r, s, v); + } + + /// @notice sets finalityThresholdExecuted to match minFinalityThreshold in the message + /// @dev in production, the attestation service fills in finalityThresholdExecuted. + /// in tests, we set it to minFinalityThreshold so receiveMessage doesn't revert. + /// offset 140 = minFinalityThreshold (uint32), offset 144 = finalityThresholdExecuted (uint32) + function _setFinalityExecuted(bytes memory message) internal pure { + require(message.length >= 148, "CctpV2Helper: message too short for finality"); + assembly { + let minFinality := shr(224, mload(add(message, 172))) + // write minFinality into finalityThresholdExecuted (offset 144, 4 bytes) + let word := mload(add(message, 176)) + // clear top 4 bytes and set to minFinality + word := or(shl(224, minFinality), and(word, 0x00000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffff)) + mstore(add(message, 176), word) + } + } + + /// @notice clears the usedNonces entry so the message can be relayed on forked state + /// @param message the raw CCTP message bytes + function _clearUsedNonce(bytes memory message) internal { + bytes32 nonce = _getNonce(message); + bytes32 storageKey = keccak256(abi.encode(nonce, USED_NONCES_SLOT)); + vm.store(MESSAGE_TRANSMITTER_V2, storageKey, bytes32(0)); + } + + /// @notice extracts nonce from CCTP message bytes + /// @dev offset 12, 32 bytes (after version[4] + sourceDomain[4] + destinationDomain[4]) + function _getNonce(bytes memory message) internal pure returns (bytes32) { + require(message.length >= 44, "CctpV2Helper: message too short for nonce"); + bytes32 nonce; + assembly { + nonce := mload(add(message, 44)) + } + return nonce; + } + + /// @notice extracts sourceDomain from CCTP message bytes + /// @dev offset 4, 4 bytes (after version[4]) + function _getSourceDomain(bytes memory message) internal pure returns (uint32) { + require(message.length >= 8, "CctpV2Helper: message too short for srcDomain"); + uint32 srcDomain; + assembly { + srcDomain := shr(224, mload(add(message, 36))) + } + return srcDomain; + } + + /// @notice extracts destinationDomain from CCTP message bytes + /// @dev offset 8, 4 bytes (after version[4] + sourceDomain[4]) + function _getDestinationDomain(bytes memory message) internal pure returns (uint32) { + require(message.length >= 12, "CctpV2Helper: message too short for destDomain"); + uint32 destDomain; + assembly { + destDomain := shr(224, mload(add(message, 40))) + } + return destDomain; + } + + /// @notice extracts destinationCaller from CCTP message bytes + /// @dev offset 108, 32 bytes + function _getDestinationCaller(bytes memory message) internal pure returns (bytes32) { + require(message.length >= 140, "CctpV2Helper: message too short for destCaller"); + bytes32 destCaller; + assembly { + destCaller := mload(add(message, 140)) + } + return destCaller; + } +} diff --git a/src/cctp/interfaces/IMessageTransmitterV2.sol b/src/cctp/interfaces/IMessageTransmitterV2.sol new file mode 100644 index 0000000..4a023e0 --- /dev/null +++ b/src/cctp/interfaces/IMessageTransmitterV2.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +/// @title IMessageTransmitterV2 +/// @notice Minimal interface for Circle's CCTP V2 MessageTransmitter on destination chain +interface IMessageTransmitterV2 { + /// @notice Receives a CCTP message and its attestation, verifying signatures and executing the message + function receiveMessage(bytes calldata message, bytes calldata attestation) external returns (bool); + + /// @notice Returns the address of the attester manager + function attesterManager() external view returns (address); + + /// @notice Enables a new attester + function enableAttester(address newAttester) external; + + /// @notice Sets the signature threshold for attestation verification + function setSignatureThreshold(uint256 newSignatureThreshold) external; + + /// @notice Returns the number of enabled attesters + function getNumEnabledAttesters() external view returns (uint256); + + /// @notice Returns the enabled attester at the given index + function getEnabledAttester(uint256 index) external view returns (address); + + /// @notice Checks if an address is an enabled attester + function isEnabledAttester(address attester) external view returns (bool); +} diff --git a/test/CctpV2.t.sol b/test/CctpV2.t.sol new file mode 100644 index 0000000..f749a23 --- /dev/null +++ b/test/CctpV2.t.sol @@ -0,0 +1,312 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +import "forge-std/Test.sol"; +import {CctpV2Helper} from "src/cctp/CctpV2Helper.sol"; + +interface IERC20 { + function balanceOf(address account) external view returns (uint256); + function approve(address spender, uint256 amount) external returns (bool); +} + +interface ITokenMessengerV2 { + function depositForBurnWithHook( + uint256 amount, + uint32 destinationDomain, + bytes32 mintRecipient, + address burnToken, + bytes32 destinationCaller, + uint256 maxFee, + uint32 minFinalityThreshold, + bytes memory hookData + ) external; +} + +contract CctpV2HelperTest is Test { + CctpV2Helper cctpHelper; + + uint256 ETH_FORK_ID; + uint256 ARB_FORK_ID; + + /// @dev CCTP domain IDs (NOT EVM chain IDs) + uint32 constant DOMAIN_ETH = 0; + uint32 constant DOMAIN_ARBITRUM = 3; + + /// @dev CCTP V2 contracts (same address on all chains via CREATE2) + address constant TOKEN_MESSENGER_V2 = 0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d; + + /// @dev USDC addresses + address constant USDC_ETH = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address constant USDC_ARB = 0xaf88d065e77c8cC2239327C5EDb3A432268e5831; + + /// @dev USDC balances mapping storage slot (FiatTokenV2) + uint256 constant USDC_BALANCE_SLOT = 9; + + /// @dev test account + address constant ALICE = address(0xA11CE); + + string RPC_ETH_MAINNET = vm.envString("ETH_MAINNET_RPC_URL"); + string RPC_ARBITRUM_MAINNET = vm.envString("ARBITRUM_MAINNET_RPC_URL"); + + function setUp() external { + ETH_FORK_ID = vm.createSelectFork(RPC_ETH_MAINNET, 25_035_000); + cctpHelper = new CctpV2Helper(0); + + ARB_FORK_ID = vm.createFork(RPC_ARBITRUM_MAINNET, 459_930_000); + } + + /// @dev sets USDC balance directly via vm.store (avoids deal breaking USDC proxy) + function _dealUsdc(address token, address to, uint256 amount) internal { + bytes32 slot = keccak256(abi.encode(to, USDC_BALANCE_SLOT)); + vm.store(token, slot, bytes32(amount)); + } + + /// @notice end-to-end: burn USDC on ETH, relay via helper, verify mint on Arbitrum + function testSimpleCctpV2() external { + uint256 amount = 1000e6; // 1000 USDC + + /// source chain: burn USDC on ETH + vm.selectFork(ETH_FORK_ID); + _dealUsdc(USDC_ETH, ALICE, amount); + + vm.startPrank(ALICE); + IERC20(USDC_ETH).approve(TOKEN_MESSENGER_V2, amount); + + vm.recordLogs(); + + ITokenMessengerV2(TOKEN_MESSENGER_V2).depositForBurnWithHook( + amount, + DOMAIN_ARBITRUM, + bytes32(uint256(uint160(ALICE))), // mintRecipient + USDC_ETH, + bytes32(0), // destinationCaller: anyone can relay + 0, // maxFee + 2000, // minFinalityThreshold: standard finality + abi.encode(uint256(1)) // hookData: non-empty required + ); + vm.stopPrank(); + + Vm.Log[] memory logs = vm.getRecordedLogs(); + + /// record destination balance before relay + vm.selectFork(ARB_FORK_ID); + uint256 balanceBefore = IERC20(USDC_ARB).balanceOf(ALICE); + vm.selectFork(ETH_FORK_ID); + + /// relay the CCTP message to destination + cctpHelper.help(DOMAIN_ARBITRUM, ARB_FORK_ID, logs); + + /// verify USDC minted on destination + vm.selectFork(ARB_FORK_ID); + uint256 balanceAfter = IERC20(USDC_ARB).balanceOf(ALICE); + assertGt(balanceAfter, balanceBefore, "USDC should have been minted on Arbitrum"); + /// amount minus any fees + assertGe(balanceAfter - balanceBefore, amount - 1e6, "Minted amount should be close to burned amount"); + } + + /// @notice test that destinationCaller restriction works + function testCctpV2WithDestinationCaller() external { + uint256 amount = 500e6; + address relayer = address(0xBEEF); + + vm.selectFork(ETH_FORK_ID); + _dealUsdc(USDC_ETH, ALICE, amount); + + vm.startPrank(ALICE); + IERC20(USDC_ETH).approve(TOKEN_MESSENGER_V2, amount); + + vm.recordLogs(); + + ITokenMessengerV2(TOKEN_MESSENGER_V2).depositForBurnWithHook( + amount, + DOMAIN_ARBITRUM, + bytes32(uint256(uint160(ALICE))), + USDC_ETH, + bytes32(uint256(uint160(relayer))), // only relayer can call receiveMessage + 0, + 2000, + abi.encode(uint256(1)) + ); + vm.stopPrank(); + + Vm.Log[] memory logs = vm.getRecordedLogs(); + + /// relay — helper should prank as destinationCaller + cctpHelper.help(DOMAIN_ARBITRUM, ARB_FORK_ID, logs); + + /// verify USDC minted + vm.selectFork(ARB_FORK_ID); + uint256 balance = IERC20(USDC_ARB).balanceOf(ALICE); + assertGt(balance, 0, "USDC should have been minted for restricted relay"); + } + + /// @notice test that anonymous events (zero topics) don't cause OOB revert + function testCctpV2HandlesZeroTopicLogs() external { + uint256 amount = 100e6; + + vm.selectFork(ETH_FORK_ID); + _dealUsdc(USDC_ETH, ALICE, amount); + + vm.startPrank(ALICE); + IERC20(USDC_ETH).approve(TOKEN_MESSENGER_V2, amount); + + vm.recordLogs(); + + ITokenMessengerV2(TOKEN_MESSENGER_V2).depositForBurnWithHook( + amount, + DOMAIN_ARBITRUM, + bytes32(uint256(uint160(ALICE))), + USDC_ETH, + bytes32(0), + 0, + 2000, + abi.encode(uint256(1)) + ); + vm.stopPrank(); + + Vm.Log[] memory realLogs = vm.getRecordedLogs(); + + /// build a new array with a zero-topic log prepended + Vm.Log[] memory logs = new Vm.Log[](realLogs.length + 1); + /// anonymous event: zero topics, some data, arbitrary emitter + logs[0].topics = new bytes32[](0); + logs[0].data = abi.encode(uint256(42)); + logs[0].emitter = address(0xDEAD); + for (uint256 i; i < realLogs.length; i++) { + logs[i + 1] = realLogs[i]; + } + + /// should not revert and should still relay the real message + vm.selectFork(ARB_FORK_ID); + uint256 balanceBefore = IERC20(USDC_ARB).balanceOf(ALICE); + vm.selectFork(ETH_FORK_ID); + + cctpHelper.help(DOMAIN_ARBITRUM, ARB_FORK_ID, logs); + + vm.selectFork(ARB_FORK_ID); + uint256 balanceAfter = IERC20(USDC_ARB).balanceOf(ALICE); + assertGt(balanceAfter, balanceBefore, "USDC should still be minted despite zero-topic log"); + } + + /// @notice test that replaying the same logs doesn't double-mint + function testCctpV2NoDuplicateRelay() external { + uint256 amount = 1000e6; + + vm.selectFork(ETH_FORK_ID); + _dealUsdc(USDC_ETH, ALICE, amount); + + vm.startPrank(ALICE); + IERC20(USDC_ETH).approve(TOKEN_MESSENGER_V2, amount); + + vm.recordLogs(); + + ITokenMessengerV2(TOKEN_MESSENGER_V2).depositForBurnWithHook( + amount, + DOMAIN_ARBITRUM, + bytes32(uint256(uint160(ALICE))), + USDC_ETH, + bytes32(0), + 0, + 2000, + abi.encode(uint256(1)) + ); + vm.stopPrank(); + + Vm.Log[] memory logs = vm.getRecordedLogs(); + + /// first relay + cctpHelper.help(DOMAIN_ARBITRUM, ARB_FORK_ID, logs); + + vm.selectFork(ARB_FORK_ID); + uint256 balanceAfterFirst = IERC20(USDC_ARB).balanceOf(ALICE); + vm.selectFork(ETH_FORK_ID); + + /// second relay with the same logs — should be a no-op + cctpHelper.help(DOMAIN_ARBITRUM, ARB_FORK_ID, logs); + + vm.selectFork(ARB_FORK_ID); + uint256 balanceAfterSecond = IERC20(USDC_ARB).balanceOf(ALICE); + assertEq(balanceAfterSecond, balanceAfterFirst, "Balance should not change on duplicate relay"); + } + + /// @notice test that emitter filter skips MessageSent from wrong address + function testCctpV2EmitterFilter() external { + uint256 amount = 100e6; + + vm.selectFork(ETH_FORK_ID); + _dealUsdc(USDC_ETH, ALICE, amount); + + vm.startPrank(ALICE); + IERC20(USDC_ETH).approve(TOKEN_MESSENGER_V2, amount); + + vm.recordLogs(); + + ITokenMessengerV2(TOKEN_MESSENGER_V2).depositForBurnWithHook( + amount, + DOMAIN_ARBITRUM, + bytes32(uint256(uint160(ALICE))), + USDC_ETH, + bytes32(0), + 0, + 2000, + abi.encode(uint256(1)) + ); + vm.stopPrank(); + + Vm.Log[] memory logs = vm.getRecordedLogs(); + + vm.selectFork(ARB_FORK_ID); + uint256 balanceBefore = IERC20(USDC_ARB).balanceOf(ALICE); + vm.selectFork(ETH_FORK_ID); + + /// use a bogus emitter filter — no logs should match + address bogusEmitter = address(0x1234); + cctpHelper.help(DOMAIN_ARBITRUM, ARB_FORK_ID, logs, bogusEmitter); + + vm.selectFork(ARB_FORK_ID); + uint256 balanceAfter = IERC20(USDC_ARB).balanceOf(ALICE); + assertEq(balanceAfter, balanceBefore, "Balance should be unchanged when emitter doesn't match"); + } + + /// @notice test that non-matching domain logs are skipped + function testCctpV2SkipsNonMatchingDomain() external { + uint256 amount = 100e6; + + vm.selectFork(ETH_FORK_ID); + _dealUsdc(USDC_ETH, ALICE, amount); + + vm.startPrank(ALICE); + IERC20(USDC_ETH).approve(TOKEN_MESSENGER_V2, amount); + + vm.recordLogs(); + + /// send to Arbitrum (domain 3) but filter for Base (domain 6) + ITokenMessengerV2(TOKEN_MESSENGER_V2).depositForBurnWithHook( + amount, + DOMAIN_ARBITRUM, + bytes32(uint256(uint160(ALICE))), + USDC_ETH, + bytes32(0), + 0, + 2000, + abi.encode(uint256(1)) + ); + vm.stopPrank(); + + Vm.Log[] memory logs = vm.getRecordedLogs(); + + /// record balance on Arbitrum before + vm.selectFork(ARB_FORK_ID); + uint256 balanceBefore = IERC20(USDC_ARB).balanceOf(ALICE); + vm.selectFork(ETH_FORK_ID); + + /// try to relay with wrong domain (Base = 6); should be a no-op + uint32 DOMAIN_BASE = 6; + cctpHelper.help(DOMAIN_BASE, ARB_FORK_ID, logs); + + /// balance should be unchanged + vm.selectFork(ARB_FORK_ID); + uint256 balanceAfter = IERC20(USDC_ARB).balanceOf(ALICE); + assertEq(balanceAfter, balanceBefore, "Balance should be unchanged for non-matching domain"); + } +}