diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 283f602..250fb1a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,7 @@ jobs: ARBITRUM_NOVA_RPC_URL: ${{secrets.ARBITRUM_NOVA_RPC_URL}} GNOSIS_CHAIN_RPC_URL: ${{secrets.GNOSIS_CHAIN_RPC_URL}} BASE_RPC_URL: ${{secrets.BASE_RPC_URL}} + POLYGON_RPC_URL: ${{secrets.POLYGON_RPC_URL}} run: FOUNDRY_PROFILE=ci forge test coverage: @@ -58,6 +59,7 @@ jobs: ARBITRUM_NOVA_RPC_URL: ${{secrets.ARBITRUM_NOVA_RPC_URL}} GNOSIS_CHAIN_RPC_URL: ${{secrets.GNOSIS_CHAIN_RPC_URL}} BASE_RPC_URL: ${{secrets.BASE_RPC_URL}} + POLYGON_RPC_URL: ${{secrets.POLYGON_RPC_URL}} run: forge coverage --report summary --report lcov # To ignore coverage for certain directories modify the paths in this step as needed. The diff --git a/src/CCTPReceiver.sol b/src/CCTPReceiver.sol new file mode 100644 index 0000000..8f6860d --- /dev/null +++ b/src/CCTPReceiver.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.0; + +/** + * @title CCTPReceiver + * @notice Receive messages from CCTP-style bridge. + */ +abstract contract CCTPReceiver { + + address public immutable destinationMessenger; + uint32 public immutable sourceDomainId; + address public immutable sourceAuthority; + + constructor( + address _destinationMessenger, + uint32 _sourceDomainId, + address _sourceAuthority + ) { + destinationMessenger = _destinationMessenger; + sourceDomainId = _sourceDomainId; + sourceAuthority = _sourceAuthority; + } + + function _onlyCrossChainMessage() internal view { + require(msg.sender == address(this), "Receiver/invalid-sender"); + } + + modifier onlyCrossChainMessage() { + _onlyCrossChainMessage(); + _; + } + + function handleReceiveMessage( + uint32 sourceDomain, + bytes32 sender, + bytes calldata messageBody + ) external returns (bool) { + require(msg.sender == destinationMessenger, "Receiver/invalid-sender"); + require(sourceDomainId == sourceDomain, "Receiver/invalid-sourceDomain"); + require(sender == bytes32(uint256(uint160(sourceAuthority))), "Receiver/invalid-sourceAuthority"); + + (bool success, bytes memory ret) = address(this).call(messageBody); + if (!success) { + assembly { + revert(add(ret, 0x20), mload(ret)) + } + } + + return true; + } + +} diff --git a/src/XChainForwarders.sol b/src/XChainForwarders.sol index d0c60b2..4d0e482 100644 --- a/src/XChainForwarders.sol +++ b/src/XChainForwarders.sol @@ -32,6 +32,14 @@ interface ICrossDomainZkEVM { ) external payable; } +interface ICrossDomainCircleCCTP { + function sendMessage( + uint32 destinationDomain, + bytes32 recipient, + bytes calldata messageBody + ) external; +} + /** * @title XChainForwarders * @notice Helper functions to abstract over L1 -> L2 message passing. @@ -196,4 +204,59 @@ library XChainForwarders { ); } + /// ================================ CCTP ================================ + + function sendMessageCCTP( + address sourceMessenger, + uint32 destinationDomainId, + bytes32 recipient, + bytes memory messageBody + ) internal { + ICrossDomainCircleCCTP(sourceMessenger).sendMessage( + destinationDomainId, + recipient, + messageBody + ); + } + + function sendMessageCCTP( + address sourceMessenger, + uint32 destinationDomainId, + address recipient, + bytes memory messageBody + ) internal { + sendMessageCCTP( + sourceMessenger, + destinationDomainId, + bytes32(uint256(uint160(recipient))), + messageBody + ); + } + + function sendMessageCircleCCTP( + uint32 destinationDomainId, + bytes32 recipient, + bytes memory messageBody + ) internal { + sendMessageCCTP( + 0x0a992d191DEeC32aFe36203Ad87D7d289a738F81, + destinationDomainId, + recipient, + messageBody + ); + } + + function sendMessageCircleCCTP( + uint32 destinationDomainId, + address recipient, + bytes memory messageBody + ) internal { + sendMessageCCTP( + 0x0a992d191DEeC32aFe36203Ad87D7d289a738F81, + destinationDomainId, + bytes32(uint256(uint160(recipient))), + messageBody + ); + } + } diff --git a/src/testing/CircleCCTPDomain.sol b/src/testing/CircleCCTPDomain.sol new file mode 100644 index 0000000..93591ab --- /dev/null +++ b/src/testing/CircleCCTPDomain.sol @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity >=0.8.0; + +import { StdChains } from "forge-std/StdChains.sol"; +import { Vm } from "forge-std/Vm.sol"; + +import { Domain, BridgedDomain } from "./BridgedDomain.sol"; +import { RecordedLogs } from "./RecordedLogs.sol"; + +interface MessengerLike { + function receiveMessage(bytes calldata message, bytes calldata attestation) external returns (bool success); +} + +contract CircleCCTPDomain is BridgedDomain { + + bytes32 private constant SENT_MESSAGE_TOPIC = keccak256("MessageSent(bytes)"); + + MessengerLike public SOURCE_MESSENGER; + MessengerLike public DESTINATION_MESSENGER; + + uint256 internal lastFromHostLogIndex; + uint256 internal lastToHostLogIndex; + + constructor(StdChains.Chain memory _chain, Domain _hostDomain) Domain(_chain) BridgedDomain(_hostDomain) { + SOURCE_MESSENGER = MessengerLike(_getMessengerFromChainAlias(_hostDomain.details().chainAlias)); + DESTINATION_MESSENGER = MessengerLike(_getMessengerFromChainAlias(_chain.chainAlias)); + + // Set minimum required signatures to zero for both domains + selectFork(); + vm.store( + address(DESTINATION_MESSENGER), + bytes32(uint256(4)), + 0 + ); + hostDomain.selectFork(); + vm.store( + address(SOURCE_MESSENGER), + bytes32(uint256(4)), + 0 + ); + + vm.recordLogs(); + } + + function _getMessengerFromChainAlias(string memory chainAlias) internal pure returns (address) { + bytes32 name = keccak256(bytes(chainAlias)); + if (name == keccak256("mainnet")) { + return 0x0a992d191DEeC32aFe36203Ad87D7d289a738F81; + } else if (name == keccak256("avalanche")) { + return 0x8186359aF5F57FbB40c6b14A588d2A59C0C29880; + } else if (name == keccak256("optimism")) { + return 0x4D41f22c5a0e5c74090899E5a8Fb597a8842b3e8; + } else if (name == keccak256("arbitrum_one")) { + return 0xC30362313FBBA5cf9163F0bb16a0e01f01A896ca; + } else if (name == keccak256("base")) { + return 0xAD09780d193884d503182aD4588450C416D6F9D4; + } else if (name == keccak256("polygon")) { + return 0xF3be9355363857F3e001be68856A2f96b4C39Ba9; + } else { + revert("Unsupported chain"); + } + } + + function relayFromHost(bool switchToGuest) external override { + selectFork(); + + // Read all L1 -> L2 messages and relay them under CCTP fork + Vm.Log[] memory logs = RecordedLogs.getLogs(); + for (; lastFromHostLogIndex < logs.length; lastFromHostLogIndex++) { + Vm.Log memory log = logs[lastFromHostLogIndex]; + if (log.topics[0] == SENT_MESSAGE_TOPIC && log.emitter == address(SOURCE_MESSENGER)) { + DESTINATION_MESSENGER.receiveMessage(removeFirst64Bytes(log.data), ""); + } + } + + if (!switchToGuest) { + hostDomain.selectFork(); + } + } + + function relayToHost(bool switchToHost) external override { + hostDomain.selectFork(); + + // Read all L2 -> L1 messages and relay them under host fork + Vm.Log[] memory logs = RecordedLogs.getLogs(); + for (; lastToHostLogIndex < logs.length; lastToHostLogIndex++) { + Vm.Log memory log = logs[lastToHostLogIndex]; + if (log.topics[0] == SENT_MESSAGE_TOPIC && log.emitter == address(DESTINATION_MESSENGER)) { + SOURCE_MESSENGER.receiveMessage(removeFirst64Bytes(log.data), ""); + } + } + + if (!switchToHost) { + selectFork(); + } + } + + function removeFirst64Bytes(bytes memory inputData) public pure returns (bytes memory) { + bytes memory returnValue = new bytes(inputData.length - 64); + for (uint256 i = 0; i < inputData.length - 64; i++) { + returnValue[i] = inputData[i + 64]; + } + return returnValue; + } + +} diff --git a/test/CCTPIntegration.t.sol b/test/CCTPIntegration.t.sol new file mode 100644 index 0000000..a3ed28d --- /dev/null +++ b/test/CCTPIntegration.t.sol @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity >=0.8.0; + +import "./IntegrationBase.t.sol"; + +import { CircleCCTPDomain } from "../src/testing/CircleCCTPDomain.sol"; + +import { CCTPReceiver } from "../src/CCTPReceiver.sol"; + +contract MessageOrderingCCTP is MessageOrdering, CCTPReceiver { + + constructor( + address _destinationMessenger, + uint32 _sourceDomainId, + address _sourceAuthority + ) CCTPReceiver( + _destinationMessenger, + _sourceDomainId, + _sourceAuthority + ) {} + + function push(uint256 messageId) public override onlyCrossChainMessage { + super.push(messageId); + } + +} + +contract CircleCCTPIntegrationTest is IntegrationBaseTest { + + address l2Authority = makeAddr("l2Authority"); + + function test_avalanche() public { + CircleCCTPDomain cctp = new CircleCCTPDomain(getChain("avalanche"), mainnet); + checkCircleCCTPStyle(cctp, 1); + } + + function test_optimism() public { + CircleCCTPDomain cctp = new CircleCCTPDomain(getChain("optimism"), mainnet); + checkCircleCCTPStyle(cctp, 2); + } + + function test_arbitrum_one() public { + CircleCCTPDomain cctp = new CircleCCTPDomain(getChain("arbitrum_one"), mainnet); + checkCircleCCTPStyle(cctp, 3); + } + + function test_base() public { + CircleCCTPDomain cctp = new CircleCCTPDomain(getChain("base"), mainnet); + checkCircleCCTPStyle(cctp, 6); + } + + function test_polygon() public { + CircleCCTPDomain cctp = new CircleCCTPDomain(getChain("polygon"), mainnet); + checkCircleCCTPStyle(cctp, 7); + } + + function checkCircleCCTPStyle(CircleCCTPDomain cctp, uint32 destinationDomainId) public { + Domain host = cctp.hostDomain(); + uint32 sourceDomainId = 0; // Ethereum + + host.selectFork(); + + MessageOrderingCCTP moHost = new MessageOrderingCCTP( + address(cctp.SOURCE_MESSENGER()), + destinationDomainId, + l2Authority + ); + + cctp.selectFork(); + + MessageOrderingCCTP moCCTP = new MessageOrderingCCTP( + address(cctp.DESTINATION_MESSENGER()), + sourceDomainId, + l1Authority + ); + + // Queue up some L2 -> L1 messages + vm.startPrank(l2Authority); + XChainForwarders.sendMessageCCTP( + address(cctp.DESTINATION_MESSENGER()), + sourceDomainId, + address(moHost), + abi.encodeWithSelector(MessageOrdering.push.selector, 3) + ); + XChainForwarders.sendMessageCCTP( + address(cctp.DESTINATION_MESSENGER()), + sourceDomainId, + address(moHost), + abi.encodeWithSelector(MessageOrdering.push.selector, 4) + ); + vm.stopPrank(); + + assertEq(moCCTP.length(), 0); + + // Do not relay right away + host.selectFork(); + + // Queue up two more L1 -> L2 messages + vm.startPrank(l1Authority); + XChainForwarders.sendMessageCircleCCTP( + destinationDomainId, + address(moCCTP), + abi.encodeWithSelector(MessageOrdering.push.selector, 1) + ); + XChainForwarders.sendMessageCircleCCTP( + destinationDomainId, + address(moCCTP), + abi.encodeWithSelector(MessageOrdering.push.selector, 2) + ); + vm.stopPrank(); + + assertEq(moHost.length(), 0); + + cctp.relayFromHost(true); + + assertEq(moCCTP.length(), 2); + assertEq(moCCTP.messages(0), 1); + assertEq(moCCTP.messages(1), 2); + + cctp.relayToHost(true); + + assertEq(moHost.length(), 2); + assertEq(moHost.messages(0), 3); + assertEq(moHost.messages(1), 4); + + // Validate the message receiver failure modes + vm.startPrank(notL1Authority); + XChainForwarders.sendMessageCircleCCTP( + destinationDomainId, + address(moCCTP), + abi.encodeWithSelector(MessageOrdering.push.selector, 999) + ); + vm.stopPrank(); + + vm.expectRevert("Receiver/invalid-sourceAuthority"); + cctp.relayFromHost(true); + + cctp.selectFork(); + vm.expectRevert("Receiver/invalid-sender"); + moCCTP.push(999); + + vm.expectRevert("Receiver/invalid-sender"); + moCCTP.handleReceiveMessage(0, bytes32(uint256(uint160(l1Authority))), abi.encodeWithSelector(MessageOrdering.push.selector, 999)); + + assertEq(moCCTP.sourceDomainId(), 0); + vm.prank(address(cctp.DESTINATION_MESSENGER())); + vm.expectRevert("Receiver/invalid-sourceDomain"); + moCCTP.handleReceiveMessage(1, bytes32(uint256(uint160(l1Authority))), abi.encodeWithSelector(MessageOrdering.push.selector, 999)); + } + +}