From dd07d57293f66b567b4945a73be484e18da6384b Mon Sep 17 00:00:00 2001 From: Yorke Rhodes IV Date: Wed, 15 Oct 2025 11:22:26 -0400 Subject: [PATCH 1/7] FOrce message to be received in cctpism.verify --- solidity/contracts/token/TokenBridgeCctpBase.sol | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/solidity/contracts/token/TokenBridgeCctpBase.sol b/solidity/contracts/token/TokenBridgeCctpBase.sol index 0d9b000e81..3705eb5278 100644 --- a/solidity/contracts/token/TokenBridgeCctpBase.sol +++ b/solidity/contracts/token/TokenBridgeCctpBase.sol @@ -259,19 +259,9 @@ abstract contract TokenBridgeCctpBase is revert("Invalid circle recipient"); } - bytes32 nonce = _getCircleNonce(cctpMessage); - // Receive only if the nonce hasn't been used before - if (messageTransmitter.usedNonces(nonce) == 0) { - require( - messageTransmitter.receiveMessage( - cctpMessageBytes, - attestation - ), - "Failed to receive message" - ); - } - - return true; + // This will revert after the first successful call, + // so the ISM will only return true once for this message + return messageTransmitter.receiveMessage(cctpMessageBytes, attestation); } function _offchainLookupCalldata( From ec7214914117fa7bee8738957925719c976ba844 Mon Sep 17 00:00:00 2001 From: Yorke Rhodes IV Date: Thu, 16 Oct 2025 13:19:07 -0400 Subject: [PATCH 2/7] Add mailbox processedAt checks --- solidity/contracts/interfaces/IMailbox.sol | 2 + .../contracts/token/TokenBridgeCctpBase.sol | 47 +++++++++++--- .../contracts/token/TokenBridgeCctpV1.sol | 11 +--- .../contracts/token/TokenBridgeCctpV2.sol | 10 ++- solidity/test/token/TokenBridgeCctp.t.sol | 64 +++++++++++++++++++ 5 files changed, 111 insertions(+), 23 deletions(-) diff --git a/solidity/contracts/interfaces/IMailbox.sol b/solidity/contracts/interfaces/IMailbox.sol index 08a70102fa..f8e04cdafc 100644 --- a/solidity/contracts/interfaces/IMailbox.sol +++ b/solidity/contracts/interfaces/IMailbox.sol @@ -48,6 +48,8 @@ interface IMailbox { function delivered(bytes32 messageId) external view returns (bool); + function processedAt(bytes32 messageId) external view returns (uint48); + function defaultIsm() external view returns (IInterchainSecurityModule); function defaultHook() external view returns (IPostDispatchHook); diff --git a/solidity/contracts/token/TokenBridgeCctpBase.sol b/solidity/contracts/token/TokenBridgeCctpBase.sol index 3705eb5278..5589534c64 100644 --- a/solidity/contracts/token/TokenBridgeCctpBase.sol +++ b/solidity/contracts/token/TokenBridgeCctpBase.sol @@ -204,10 +204,6 @@ abstract contract TokenBridgeCctpBase is bytes29 cctpMessage ) internal pure virtual returns (uint32); - function _getCircleNonce( - bytes29 cctpMessage - ) internal pure virtual returns (bytes32); - function _validateTokenMessage( bytes calldata hyperlaneMessage, bytes29 cctpMessage @@ -224,7 +220,26 @@ abstract contract TokenBridgeCctpBase is bytes32 messageId ) internal virtual; - // @dev Enforces that the CCTP message source domain and nonce matches the Hyperlane message origin and nonce. + /** + * @dev Verifies that the CCTP message matches the Hyperlane message. + * + * Ensures this ISM is only being called during legitimate + * message delivery by checking that processedAt(messageId) == block.number. + * This prevents direct calls to ISM.verify() + * that would deliver the CCTP message without delivering the Hyperlane message. + * + * For token messages: + * - Validates that CCTP burn message fields match Hyperlane message fields + * - Attempts to process the CCTP message via receiveMessage() + * - If nonce already used (e.g., by Circle's relayer), receiveMessage() will revert + * - This is acceptable because tokens were already delivered + * - WARNING: this means the Mailbox delivery will not reflect token delivery + * + * For GMP messages: + * - Validates that CCTP message body contains the Hyperlane message ID + * - Processes the CCTP message (protected by destinationCaller=ISM) + * - Must succeed because ISM is the only valid caller + */ function verify( bytes calldata _metadata, bytes calldata _hyperlaneMessage @@ -259,9 +274,25 @@ abstract contract TokenBridgeCctpBase is revert("Invalid circle recipient"); } - // This will revert after the first successful call, - // so the ISM will only return true once for this message - return messageTransmitter.receiveMessage(cctpMessageBytes, attestation); + bytes32 messageId = _hyperlaneMessage.id(); + + // Ensures that the Hyperlane message is being processed "right now" (current block). + // Prevents someone from calling ISM.verify() directly and making the message undeliverable. + require( + mailbox.processedAt(messageId) == block.number, + "Message not being processed" + ); + + // Process the CCTP message + // For token messages: may revert if nonce already used by another relayer + // (acceptable because tokens already delivered) + // For GMP messages: must succeed (protected by destinationCaller) + require( + messageTransmitter.receiveMessage(cctpMessageBytes, attestation), + "Failed to receive message" + ); + + return true; } function _offchainLookupCalldata( diff --git a/solidity/contracts/token/TokenBridgeCctpV1.sol b/solidity/contracts/token/TokenBridgeCctpV1.sol index 41749665df..6ab5ca1886 100644 --- a/solidity/contracts/token/TokenBridgeCctpV1.sol +++ b/solidity/contracts/token/TokenBridgeCctpV1.sol @@ -48,15 +48,6 @@ contract TokenBridgeCctpV1 is TokenBridgeCctpBase, IMessageHandler { return cctpMessage._recipient().bytes32ToAddress(); } - function _getCircleNonce( - bytes29 cctpMessage - ) internal pure override returns (bytes32) { - bytes32 sourceAndNonceHash = keccak256( - abi.encodePacked(cctpMessage._sourceDomain(), cctpMessage._nonce()) - ); - return sourceAndNonceHash; - } - function _getCircleSource( bytes29 cctpMessage ) internal pure override returns (uint32) { @@ -117,6 +108,8 @@ contract TokenBridgeCctpV1 is TokenBridgeCctpBase, IMessageHandler { bytes32 /*sender*/, bytes calldata /*body*/ ) external pure override returns (bool) { + // No-op: all validation is done in verify() + // This callback is required by CCTP's IMessageHandler interface return true; } diff --git a/solidity/contracts/token/TokenBridgeCctpV2.sol b/solidity/contracts/token/TokenBridgeCctpV2.sol index 021d01168d..35ec71276e 100644 --- a/solidity/contracts/token/TokenBridgeCctpV2.sol +++ b/solidity/contracts/token/TokenBridgeCctpV2.sol @@ -93,12 +93,6 @@ contract TokenBridgeCctpV2 is TokenBridgeCctpBase, IMessageHandlerV2 { return cctpMessage._getRecipient().bytes32ToAddress(); } - function _getCircleNonce( - bytes29 cctpMessage - ) internal pure override returns (bytes32) { - return cctpMessage._getNonce(); - } - function _getCircleSource( bytes29 cctpMessage ) internal pure override returns (uint32) { @@ -163,6 +157,8 @@ contract TokenBridgeCctpV2 is TokenBridgeCctpBase, IMessageHandlerV2 { uint32 /*finalityThresholdExecuted*/, bytes calldata /*messageBody*/ ) external pure override returns (bool) { + // No-op: all validation is done in verify() + // This callback is required by CCTP's IMessageHandlerV2 interface return true; } @@ -173,6 +169,8 @@ contract TokenBridgeCctpV2 is TokenBridgeCctpBase, IMessageHandlerV2 { uint32 /*finalityThresholdExecuted*/, bytes calldata /*messageBody*/ ) external pure override returns (bool) { + // No-op: all validation is done in verify() + // This callback is required by CCTP's IMessageHandlerV2 interface return true; } diff --git a/solidity/test/token/TokenBridgeCctp.t.sol b/solidity/test/token/TokenBridgeCctp.t.sol index 67264ad1ae..d4dad550c4 100644 --- a/solidity/test/token/TokenBridgeCctp.t.sol +++ b/solidity/test/token/TokenBridgeCctp.t.sol @@ -378,6 +378,13 @@ contract TokenBridgeCctpV1Test is Test { bytes memory attestation = bytes(""); bytes memory metadata = abi.encode(cctpMessage, attestation); + // Mock processedAt to return current block number for defense-in-depth check + vm.mockCall( + address(mailboxDestination), + abi.encodeWithSelector(IMailbox.processedAt.selector), + abi.encode(uint48(block.number)) + ); + vm.expectCall( address(messageTransmitterDestination), abi.encodeCall( @@ -388,6 +395,34 @@ contract TokenBridgeCctpV1Test is Test { assertEq(tbDestination.verify(metadata, message), true); } + function test_verify_revertsWhen_messageNotBeingProcessed() public { + ( + bytes memory message, + uint64 cctpNonce, + bytes32 recipient + ) = _setupAndDispatch(); + + bytes memory cctpMessage = _encodeCctpBurnMessage( + cctpNonce, + cctpOrigin, + recipient, + amount + ); + bytes memory attestation = bytes(""); + bytes memory metadata = abi.encode(cctpMessage, attestation); + + // Mock processedAt to return 0 (message not being processed) + // This simulates calling verify() directly without going through mailbox.process() + vm.mockCall( + address(mailboxDestination), + abi.encodeWithSelector(IMailbox.processedAt.selector), + abi.encode(uint48(0)) + ); + + vm.expectRevert(bytes("Message not being processed")); + tbDestination.verify(metadata, message); + } + function _upgrade(TokenBridgeCctpBase bridge) internal virtual { TokenBridgeCctpV1 newImplementation = new TokenBridgeCctpV1( address(bridge.wrappedToken()), @@ -422,6 +457,14 @@ contract TokenBridgeCctpV1Test is Test { recipient.verify(metadata, message); _upgrade(recipient); + + // Mock processedAt to return current block number for defense-in-depth check + vm.mockCall( + address(recipient.mailbox()), + abi.encodeWithSelector(IMailbox.processedAt.selector), + abi.encode(uint48(block.number)) + ); + assert(recipient.verify(metadata, message)); } @@ -759,6 +802,13 @@ contract TokenBridgeCctpV1Test is Test { vm.prank(ism.owner()); ism.enrollRemoteRouter(origin, deployer.addressToBytes32()); + // Mock processedAt to return current block number for defense-in-depth check + vm.mockCall( + address(ism.mailbox()), + abi.encodeWithSelector(IMailbox.processedAt.selector), + abi.encode(uint48(block.number)) + ); + vm.expectCall( address(ism), abi.encode(TokenBridgeCctpV1.handleReceiveMessage.selector) @@ -1122,6 +1172,13 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { memory attestation = hex"fdaca657526b164d6b09678297565d40e1e68cad3bfb0786470b0e8bce013ee340a985970d69629af69599f3deff5cc975b3df46d2efeadfebd867d049e5e5641cba6f5e720dc86c90d8d51747619fbe2b24246e36fa0603792cb86ad88bdc06136663d6211a8d5d134cf94cf8197892a460b24a5e21715642d338530b472a325d1c"; bytes memory metadata = abi.encode(cctpMessage, attestation); + // Mock processedAt to return current block number for defense-in-depth check + vm.mockCall( + address(ism.mailbox()), + abi.encodeWithSelector(IMailbox.processedAt.selector), + abi.encode(uint48(block.number)) + ); + vm.expectCall( address(ism), abi.encode( @@ -1227,6 +1284,13 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { abi.encode(hook, amount) ); + // Mock processedAt to return current block number for defense-in-depth check + vm.mockCall( + address(ism.mailbox()), + abi.encodeWithSelector(IMailbox.processedAt.selector), + abi.encode(uint48(block.number)) + ); + vm.expectEmit(true, true, true, true, address(ism.tokenMessenger())); emit MintAndWithdraw(deployer, amount - fee, usdc, fee); ism.verify(metadata, message); From 5e0cb34e1a2064e650dcc87c9b6a561073b7737d Mon Sep 17 00:00:00 2001 From: Yorke Rhodes IV Date: Thu, 16 Oct 2025 13:20:58 -0400 Subject: [PATCH 3/7] Remove unnecessary mock --- solidity/test/token/TokenBridgeCctp.t.sol | 8 -------- 1 file changed, 8 deletions(-) diff --git a/solidity/test/token/TokenBridgeCctp.t.sol b/solidity/test/token/TokenBridgeCctp.t.sol index d4dad550c4..aec6991d91 100644 --- a/solidity/test/token/TokenBridgeCctp.t.sol +++ b/solidity/test/token/TokenBridgeCctp.t.sol @@ -411,14 +411,6 @@ contract TokenBridgeCctpV1Test is Test { bytes memory attestation = bytes(""); bytes memory metadata = abi.encode(cctpMessage, attestation); - // Mock processedAt to return 0 (message not being processed) - // This simulates calling verify() directly without going through mailbox.process() - vm.mockCall( - address(mailboxDestination), - abi.encodeWithSelector(IMailbox.processedAt.selector), - abi.encode(uint48(0)) - ); - vm.expectRevert(bytes("Message not being processed")); tbDestination.verify(metadata, message); } From 71a4e219ff45e62660ec2e88117a498d12175d83 Mon Sep 17 00:00:00 2001 From: Yorke Rhodes IV Date: Thu, 16 Oct 2025 16:23:16 -0400 Subject: [PATCH 4/7] Use authorized message ID pre verification pattern --- solidity/contracts/interfaces/IMailbox.sol | 2 - .../mock/MockCircleMessageTransmitter.sol | 110 ++++++++++++++-- .../mock/MockCircleTokenMessenger.sol | 37 +++++- .../contracts/token/TokenBridgeCctpBase.sol | 118 +++++++++--------- .../contracts/token/TokenBridgeCctpV1.sol | 73 +++-------- .../contracts/token/TokenBridgeCctpV2.sol | 64 +++------- solidity/test/token/TokenBridgeCctp.t.sol | 79 +++--------- 7 files changed, 247 insertions(+), 236 deletions(-) diff --git a/solidity/contracts/interfaces/IMailbox.sol b/solidity/contracts/interfaces/IMailbox.sol index f8e04cdafc..08a70102fa 100644 --- a/solidity/contracts/interfaces/IMailbox.sol +++ b/solidity/contracts/interfaces/IMailbox.sol @@ -48,8 +48,6 @@ interface IMailbox { function delivered(bytes32 messageId) external view returns (bool); - function processedAt(bytes32 messageId) external view returns (uint48); - function defaultIsm() external view returns (IInterchainSecurityModule); function defaultHook() external view returns (IPostDispatchHook); diff --git a/solidity/contracts/mock/MockCircleMessageTransmitter.sol b/solidity/contracts/mock/MockCircleMessageTransmitter.sol index fa35f16363..de749d2e14 100644 --- a/solidity/contracts/mock/MockCircleMessageTransmitter.sol +++ b/solidity/contracts/mock/MockCircleMessageTransmitter.sol @@ -3,12 +3,24 @@ pragma solidity ^0.8.13; import {IMessageTransmitter} from "../interfaces/cctp/IMessageTransmitter.sol"; import {IMessageTransmitterV2} from "../interfaces/cctp/IMessageTransmitterV2.sol"; +import {IMessageHandler} from "../interfaces/cctp/IMessageHandler.sol"; +import {IMessageHandlerV2} from "../interfaces/cctp/IMessageHandlerV2.sol"; import {MockToken} from "./MockToken.sol"; +import {TypedMemView} from "../libs/TypedMemView.sol"; +import {CctpMessageV1} from "../libs/CctpMessageV1.sol"; +import {CctpMessageV2} from "../libs/CctpMessageV2.sol"; +import {TypeCasts} from "../libs/TypeCasts.sol"; contract MockCircleMessageTransmitter is IMessageTransmitter, IMessageTransmitterV2 { + using TypedMemView for bytes; + using TypedMemView for bytes29; + using CctpMessageV1 for bytes29; + using CctpMessageV2 for bytes29; + using TypeCasts for address; + mapping(bytes32 => bool) processedNonces; MockToken token; uint32 public version; @@ -26,10 +38,52 @@ contract MockCircleMessageTransmitter is } function receiveMessage( - bytes memory, + bytes memory message, bytes calldata - ) external pure returns (bool success) { - success = true; + ) external returns (bool success) { + bytes29 cctpMessage = TypedMemView.ref(message, 0); + + // Extract recipient based on version + address recipient; + bytes32 sender; + uint32 sourceDomain; + bytes memory messageBody; + + if (version == 0) { + // V1 + recipient = _bytes32ToAddress(cctpMessage._recipient()); + sender = cctpMessage._sender(); + sourceDomain = cctpMessage._sourceDomain(); + messageBody = cctpMessage._messageBody().clone(); + } else { + // V2 + recipient = _bytes32ToAddress(cctpMessage._getRecipient()); + sender = cctpMessage._getSender(); + sourceDomain = cctpMessage._getSourceDomain(); + messageBody = cctpMessage._getMessageBody().clone(); + } + + if (version == 0) { + // V1: Call handleReceiveMessage + success = IMessageHandler(recipient).handleReceiveMessage( + sourceDomain, + sender, + messageBody + ); + } else { + // V2: Call handleReceiveUnfinalizedMessage + success = IMessageHandlerV2(recipient) + .handleReceiveUnfinalizedMessage( + sourceDomain, + sender, + 1000, // mock finality threshold + messageBody + ); + } + } + + function _bytes32ToAddress(bytes32 _buf) internal pure returns (address) { + return address(uint160(uint256(_buf))); } function hashSourceAndNonce( @@ -70,11 +124,36 @@ contract MockCircleMessageTransmitter is } function sendMessage( - uint32, - bytes32, - bytes calldata message + uint32 destinationDomain, + bytes32 recipient, + bytes calldata messageBody ) public returns (uint64) { - emit MessageSent(message); + // Format a complete CCTP message for the event based on version + bytes memory cctpMessage; + if (version == 0) { + cctpMessage = CctpMessageV1._formatMessage( + version, + 0, // sourceDomain (mock localDomain returns 0) + destinationDomain, + 0, // nonce + address(this).addressToBytes32(), + recipient, + bytes32(0), // destinationCaller (anyone can relay) + messageBody + ); + } else { + cctpMessage = CctpMessageV2._formatMessageForRelay( + version, + 0, // sourceDomain (mock localDomain returns 0) + destinationDomain, + address(this).addressToBytes32(), + recipient, + bytes32(0), // destinationCaller (anyone can relay) + 1000, // mock finality threshold + messageBody + ); + } + emit MessageSent(cctpMessage); return 0; } @@ -90,10 +169,21 @@ contract MockCircleMessageTransmitter is function sendMessage( uint32 destinationDomain, bytes32 recipient, - bytes32, - uint32, + bytes32 destinationCaller, + uint32 minFinalityThreshold, bytes calldata messageBody ) external { - sendMessage(destinationDomain, recipient, messageBody); + // V2 sendMessage: format a complete CCTP V2 message + bytes memory cctpMessage = CctpMessageV2._formatMessageForRelay( + version, + 0, // sourceDomain (mock localDomain returns 0) + destinationDomain, + address(this).addressToBytes32(), + recipient, + destinationCaller, + minFinalityThreshold, + messageBody + ); + emit MessageSent(cctpMessage); } } diff --git a/solidity/contracts/mock/MockCircleTokenMessenger.sol b/solidity/contracts/mock/MockCircleTokenMessenger.sol index b6570271ed..9bfc9852b5 100644 --- a/solidity/contracts/mock/MockCircleTokenMessenger.sol +++ b/solidity/contracts/mock/MockCircleTokenMessenger.sol @@ -3,9 +3,16 @@ pragma solidity ^0.8.13; import {ITokenMessenger, ITokenMessengerV1} from "../interfaces/cctp/ITokenMessenger.sol"; import {ITokenMessengerV2} from "../interfaces/cctp/ITokenMessengerV2.sol"; +import {IMessageHandler} from "../interfaces/cctp/IMessageHandler.sol"; +import {IMessageHandlerV2} from "../interfaces/cctp/IMessageHandlerV2.sol"; import {MockToken} from "./MockToken.sol"; -contract MockCircleTokenMessenger is ITokenMessengerV1, ITokenMessengerV2 { +contract MockCircleTokenMessenger is + ITokenMessengerV1, + ITokenMessengerV2, + IMessageHandler, + IMessageHandlerV2 +{ uint64 public nextNonce = 0; MockToken token; uint32 public version; @@ -56,4 +63,32 @@ contract MockCircleTokenMessenger is ITokenMessengerV1, ITokenMessengerV2 { ) external { depositForBurn(_amount, 0, 0, _burnToken); } + + // V1 handler + function handleReceiveMessage( + uint32, + bytes32, + bytes calldata + ) external pure override returns (bool) { + return true; + } + + // V2 handlers + function handleReceiveFinalizedMessage( + uint32, + bytes32, + uint32, + bytes calldata + ) external pure override returns (bool) { + return true; + } + + function handleReceiveUnfinalizedMessage( + uint32, + bytes32, + uint32, + bytes calldata + ) external pure override returns (bool) { + return true; + } } diff --git a/solidity/contracts/token/TokenBridgeCctpBase.sol b/solidity/contracts/token/TokenBridgeCctpBase.sol index 5589534c64..2aea454219 100644 --- a/solidity/contracts/token/TokenBridgeCctpBase.sol +++ b/solidity/contracts/token/TokenBridgeCctpBase.sol @@ -17,7 +17,7 @@ import {TypeCasts} from "../libs/TypeCasts.sol"; import {MovableCollateralRouter, MovableCollateralRouterStorage} from "./libs/MovableCollateralRouter.sol"; import {TokenRouter} from "./libs/TokenRouter.sol"; import {AbstractPostDispatchHook} from "../hooks/libs/AbstractPostDispatchHook.sol"; - +import {AbstractMessageIdAuthorizedIsm} from "../isms/hook/AbstractMessageIdAuthorizedIsm.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; @@ -39,7 +39,8 @@ abstract contract TokenBridgeCctpBaseStorage is TokenRouter { abstract contract TokenBridgeCctpBase is TokenBridgeCctpBaseStorage, AbstractCcipReadIsm, - AbstractPostDispatchHook + AbstractPostDispatchHook, + AbstractMessageIdAuthorizedIsm { using Message for bytes; using TypeCasts for bytes32; @@ -60,9 +61,13 @@ abstract contract TokenBridgeCctpBase is uint32 circle; } - /// @notice Hyperlane domain => Circle domain. + /// @notice Hyperlane domain => Domain struct. /// We use a struct to avoid ambiguity with domain 0 being unknown. - mapping(uint32 hypDomain => Domain circleDomain) internal _domainMap; + mapping(uint32 hypDomain => Domain domain) internal _hyperlaneDomainMap; + + /// @notice Circle domain => Domain struct. + // We use a struct to avoid ambiguity with domain 0 being unknown. + mapping(uint32 circleDomain => Domain domain) internal _circleDomainMap; /** * @notice Emitted when the Hyperlane domain to Circle domain mapping is updated. @@ -136,12 +141,10 @@ abstract contract TokenBridgeCctpBase is // 2. Prepare the token message with the recipient, amount, and any additional metadata in overrides uint32 circleDomain = hyperlaneDomainToCircleDomain(_destination); - bytes memory _message = _bridgeViaCircle( - circleDomain, - _recipient, - _amount + externalFee - ); + uint256 burnAmount = _amount + externalFee; + _bridgeViaCircle(circleDomain, _recipient, burnAmount); + bytes memory _message = TokenMessage.format(_recipient, burnAmount); // 3. Emit the SentTransferRemote event and 4. dispatch the message return _emitAndDispatch( @@ -171,7 +174,14 @@ abstract contract TokenBridgeCctpBase is uint32 _hyperlaneDomain, uint32 _circleDomain ) public onlyOwner { - _domainMap[_hyperlaneDomain] = Domain(_hyperlaneDomain, _circleDomain); + _hyperlaneDomainMap[_hyperlaneDomain] = Domain( + _hyperlaneDomain, + _circleDomain + ); + _circleDomainMap[_circleDomain] = Domain( + _hyperlaneDomain, + _circleDomain + ); emit DomainAdded(_hyperlaneDomain, _circleDomain); } @@ -185,7 +195,7 @@ abstract contract TokenBridgeCctpBase is function hyperlaneDomainToCircleDomain( uint32 _hyperlaneDomain ) public view returns (uint32) { - Domain memory domain = _domainMap[_hyperlaneDomain]; + Domain memory domain = _hyperlaneDomainMap[_hyperlaneDomain]; require( domain.hyperlane == _hyperlaneDomain, "Circle domain not configured" @@ -194,16 +204,24 @@ abstract contract TokenBridgeCctpBase is return domain.circle; } + function circleDomainToHyperlaneDomain( + uint32 _circleDomain + ) public view returns (uint32) { + Domain memory domain = _circleDomainMap[_circleDomain]; + require( + domain.circle == _circleDomain, + "Hyperlane domain not configured" + ); + + return domain.hyperlane; + } + function _getCCTPVersion() internal pure virtual returns (uint32); function _getCircleRecipient( bytes29 cctpMessage ) internal pure virtual returns (address); - function _getCircleSource( - bytes29 cctpMessage - ) internal pure virtual returns (uint32); - function _validateTokenMessage( bytes calldata hyperlaneMessage, bytes29 cctpMessage @@ -212,7 +230,7 @@ abstract contract TokenBridgeCctpBase is function _validateHookMessage( bytes calldata hyperlaneMessage, bytes29 cctpMessage - ) internal view virtual; + ) internal pure virtual; function _sendMessageIdToIsm( uint32 destinationDomain, @@ -222,28 +240,20 @@ abstract contract TokenBridgeCctpBase is /** * @dev Verifies that the CCTP message matches the Hyperlane message. - * - * Ensures this ISM is only being called during legitimate - * message delivery by checking that processedAt(messageId) == block.number. - * This prevents direct calls to ISM.verify() - * that would deliver the CCTP message without delivering the Hyperlane message. - * - * For token messages: - * - Validates that CCTP burn message fields match Hyperlane message fields - * - Attempts to process the CCTP message via receiveMessage() - * - If nonce already used (e.g., by Circle's relayer), receiveMessage() will revert - * - This is acceptable because tokens were already delivered - * - WARNING: this means the Mailbox delivery will not reflect token delivery - * - * For GMP messages: - * - Validates that CCTP message body contains the Hyperlane message ID - * - Processes the CCTP message (protected by destinationCaller=ISM) - * - Must succeed because ISM is the only valid caller */ function verify( bytes calldata _metadata, bytes calldata _hyperlaneMessage - ) external returns (bool) { + ) + external + override(AbstractMessageIdAuthorizedIsm, IInterchainSecurityModule) + returns (bool) + { + // check if hyperlane message has already been verified by CCTP + if (isVerified(_hyperlaneMessage)) { + return true; + } + // decode return type of CctpService.getCCTPAttestation (bytes memory cctpMessageBytes, bytes memory attestation) = abi.decode( _metadata, @@ -251,15 +261,6 @@ abstract contract TokenBridgeCctpBase is ); bytes29 cctpMessage = TypedMemView.ref(cctpMessageBytes, 0); - - // check if CCTP message source matches the hyperlane message origin - uint32 origin = _hyperlaneMessage.origin(); - uint32 sourceDomain = _getCircleSource(cctpMessage); - require( - sourceDomain == hyperlaneDomainToCircleDomain(origin), - "Invalid source domain" - ); - address circleRecipient = _getCircleRecipient(cctpMessage); // check if CCTP message is a USDC burn message if (circleRecipient == address(tokenMessenger)) { @@ -274,25 +275,22 @@ abstract contract TokenBridgeCctpBase is revert("Invalid circle recipient"); } - bytes32 messageId = _hyperlaneMessage.id(); - - // Ensures that the Hyperlane message is being processed "right now" (current block). - // Prevents someone from calling ISM.verify() directly and making the message undeliverable. - require( - mailbox.processedAt(messageId) == block.number, - "Message not being processed" - ); + return messageTransmitter.receiveMessage(cctpMessageBytes, attestation); + } - // Process the CCTP message - // For token messages: may revert if nonce already used by another relayer - // (acceptable because tokens already delivered) - // For GMP messages: must succeed (protected by destinationCaller) + function _authenticateCircleSender( + uint32 sourceDomain, + bytes32 sender + ) internal view { + uint32 hyperlaneDomain = circleDomainToHyperlaneDomain(sourceDomain); require( - messageTransmitter.receiveMessage(cctpMessageBytes, attestation), - "Failed to receive message" + _mustHaveRemoteRouter(hyperlaneDomain) == sender, + "Unauthorized circle sender" ); + } - return true; + function _isAuthorized() internal view override returns (bool) { + return msg.sender == address(messageTransmitter); } function _offchainLookupCalldata( @@ -315,6 +313,8 @@ abstract contract TokenBridgeCctpBase is } /// @inheritdoc AbstractPostDispatchHook + /// @dev Mirrors the logic in AbstractMessageIdAuthHook._postDispatch + // but using Router table instead of hook <> ISM coupling function _postDispatch( bytes calldata metadata, bytes calldata message @@ -354,5 +354,5 @@ abstract contract TokenBridgeCctpBase is uint32 _destination, bytes32 _recipient, uint256 _amount - ) internal virtual returns (bytes memory message); + ) internal virtual; } diff --git a/solidity/contracts/token/TokenBridgeCctpV1.sol b/solidity/contracts/token/TokenBridgeCctpV1.sol index 6ab5ca1886..37df1a7e0d 100644 --- a/solidity/contracts/token/TokenBridgeCctpV1.sol +++ b/solidity/contracts/token/TokenBridgeCctpV1.sol @@ -11,10 +11,6 @@ import {IMessageHandler} from "../interfaces/cctp/IMessageHandler.sol"; import {ITokenMessengerV1} from "../interfaces/cctp/ITokenMessenger.sol"; import {IMessageTransmitter} from "../interfaces/cctp/IMessageTransmitter.sol"; -// TokenMessage.metadata := uint8 cctpNonce -uint256 constant CCTP_TOKEN_BRIDGE_MESSAGE_LEN = TokenMessage.METADATA_OFFSET + - 8; - // @dev Supports only CCTP V1 contract TokenBridgeCctpV1 is TokenBridgeCctpBase, IMessageHandler { using CctpMessageV1 for bytes29; @@ -48,12 +44,6 @@ contract TokenBridgeCctpV1 is TokenBridgeCctpBase, IMessageHandler { return cctpMessage._recipient().bytes32ToAddress(); } - function _getCircleSource( - bytes29 cctpMessage - ) internal pure override returns (uint32) { - return cctpMessage._sourceDomain(); - } - function _validateTokenMessage( bytes calldata hyperlaneMessage, bytes29 cctpMessage @@ -68,13 +58,6 @@ contract TokenBridgeCctpV1 is TokenBridgeCctpBase, IMessageHandler { ); bytes calldata tokenMessage = hyperlaneMessage.body(); - _validateTokenMessageLength(tokenMessage); - - require( - uint64(bytes8(TokenMessage.metadata(tokenMessage))) == - cctpMessage._nonce(), - "Invalid nonce" - ); require( TokenMessage.amount(tokenMessage) == burnMessage._getAmount(), @@ -91,70 +74,48 @@ contract TokenBridgeCctpV1 is TokenBridgeCctpBase, IMessageHandler { function _validateHookMessage( bytes calldata hyperlaneMessage, bytes29 cctpMessage - ) internal view override { - bytes32 circleSender = cctpMessage._sender(); - require( - circleSender == _mustHaveRemoteRouter(hyperlaneMessage.origin()), - "Invalid circle sender" - ); - + ) internal pure override { bytes32 circleMessageId = cctpMessage._messageBody().index(0, 32); require(circleMessageId == hyperlaneMessage.id(), "Invalid message id"); } /// @inheritdoc IMessageHandler function handleReceiveMessage( - uint32 /*sourceDomain*/, - bytes32 /*sender*/, - bytes calldata /*body*/ - ) external pure override returns (bool) { - // No-op: all validation is done in verify() - // This callback is required by CCTP's IMessageHandler interface + uint32 sourceDomain, + bytes32 sender, + bytes calldata body + ) external override returns (bool) { + _authenticateCircleSender(sourceDomain, sender); + preVerifyMessage(_messageId(body), 0); return true; } + function _messageId(bytes calldata body) internal pure returns (bytes32) { + return bytes32(body[0:32]); + } + function _sendMessageIdToIsm( uint32 destinationDomain, bytes32 ism, bytes32 messageId ) internal override { - IMessageTransmitter(messageTransmitter).sendMessageWithCaller( + IMessageTransmitter(messageTransmitter).sendMessage( destinationDomain, ism, - ism, abi.encode(messageId) ); } - function _validateTokenMessageLength( - bytes memory _tokenMessage - ) internal pure { - require( - _tokenMessage.length == CCTP_TOKEN_BRIDGE_MESSAGE_LEN, - "Invalid message body length" - ); - } - function _bridgeViaCircle( uint32 circleDomain, bytes32 _recipient, uint256 _amount - ) internal override returns (bytes memory _message) { - uint64 nonce = ITokenMessengerV1(address(tokenMessenger)) - .depositForBurn( - _amount, - circleDomain, - _recipient, - address(wrappedToken) - ); - - _message = TokenMessage.format( - _recipient, + ) internal override { + ITokenMessengerV1(address(tokenMessenger)).depositForBurn( _amount, - abi.encodePacked(nonce) + circleDomain, + _recipient, + address(wrappedToken) ); - _validateTokenMessageLength(_message); - - return _message; } } diff --git a/solidity/contracts/token/TokenBridgeCctpV2.sol b/solidity/contracts/token/TokenBridgeCctpV2.sol index 35ec71276e..9df38cbaae 100644 --- a/solidity/contracts/token/TokenBridgeCctpV2.sol +++ b/solidity/contracts/token/TokenBridgeCctpV2.sol @@ -12,9 +12,6 @@ import {IMessageHandlerV2} from "../interfaces/cctp/IMessageHandlerV2.sol"; import {ITokenMessengerV2} from "../interfaces/cctp/ITokenMessengerV2.sol"; import {IMessageTransmitterV2} from "../interfaces/cctp/IMessageTransmitterV2.sol"; -// TokenMessage.metadata := null -uint256 constant CCTP_TOKEN_BRIDGE_MESSAGE_LEN = TokenMessage.METADATA_OFFSET; - // @dev Supports only CCTP V2 contract TokenBridgeCctpV2 is TokenBridgeCctpBase, IMessageHandlerV2 { using CctpMessageV2 for bytes29; @@ -93,21 +90,6 @@ contract TokenBridgeCctpV2 is TokenBridgeCctpBase, IMessageHandlerV2 { return cctpMessage._getRecipient().bytes32ToAddress(); } - function _getCircleSource( - bytes29 cctpMessage - ) internal pure override returns (uint32) { - return cctpMessage._getSourceDomain(); - } - - function _validateTokenMessageLength( - bytes memory tokenMessage - ) internal pure { - require( - tokenMessage.length == CCTP_TOKEN_BRIDGE_MESSAGE_LEN, - "Invalid message length" - ); - } - function _validateTokenMessage( bytes calldata hyperlaneMessage, bytes29 cctpMessage @@ -122,7 +104,6 @@ contract TokenBridgeCctpV2 is TokenBridgeCctpBase, IMessageHandlerV2 { ); bytes calldata tokenMessage = hyperlaneMessage.body(); - _validateTokenMessageLength(tokenMessage); require( TokenMessage.amount(tokenMessage) == burnMessage._getAmount(), @@ -139,41 +120,39 @@ contract TokenBridgeCctpV2 is TokenBridgeCctpBase, IMessageHandlerV2 { function _validateHookMessage( bytes calldata hyperlaneMessage, bytes29 cctpMessage - ) internal view override { - bytes32 circleSender = cctpMessage._getSender(); - require( - circleSender == _mustHaveRemoteRouter(hyperlaneMessage.origin()), - "Invalid circle sender" - ); - + ) internal pure override { bytes32 circleMessageId = cctpMessage._getMessageBody().index(0, 32); require(circleMessageId == hyperlaneMessage.id(), "Invalid message id"); } // @inheritdoc IMessageHandlerV2 function handleReceiveFinalizedMessage( - uint32 /*sourceDomain*/, - bytes32 /*sender*/, + uint32 sourceDomain, + bytes32 sender, uint32 /*finalityThresholdExecuted*/, - bytes calldata /*messageBody*/ - ) external pure override returns (bool) { - // No-op: all validation is done in verify() - // This callback is required by CCTP's IMessageHandlerV2 interface + bytes calldata messageBody + ) external override returns (bool) { + _authenticateCircleSender(sourceDomain, sender); + preVerifyMessage(_messageId(messageBody), 0); return true; } // @inheritdoc IMessageHandlerV2 function handleReceiveUnfinalizedMessage( - uint32 /*sourceDomain*/, - bytes32 /*sender*/, + uint32 sourceDomain, + bytes32 sender, uint32 /*finalityThresholdExecuted*/, - bytes calldata /*messageBody*/ - ) external pure override returns (bool) { - // No-op: all validation is done in verify() - // This callback is required by CCTP's IMessageHandlerV2 interface + bytes calldata messageBody + ) external override returns (bool) { + _authenticateCircleSender(sourceDomain, sender); + preVerifyMessage(_messageId(messageBody), 0); return true; } + function _messageId(bytes calldata body) internal pure returns (bytes32) { + return bytes32(body[0:32]); + } + function _sendMessageIdToIsm( uint32 destinationDomain, bytes32 ism, @@ -182,7 +161,7 @@ contract TokenBridgeCctpV2 is TokenBridgeCctpBase, IMessageHandlerV2 { IMessageTransmitterV2(address(messageTransmitter)).sendMessage( destinationDomain, ism, - ism, + bytes32(0), // allow anyone to relay minFinalityThreshold, abi.encode(messageId) ); @@ -192,7 +171,7 @@ contract TokenBridgeCctpV2 is TokenBridgeCctpBase, IMessageHandlerV2 { uint32 circleDomain, bytes32 _recipient, uint256 _amount - ) internal override returns (bytes memory message) { + ) internal override { ITokenMessengerV2(address(tokenMessenger)).depositForBurn( _amount, circleDomain, @@ -202,10 +181,5 @@ contract TokenBridgeCctpV2 is TokenBridgeCctpBase, IMessageHandlerV2 { maxFeeBps, minFinalityThreshold ); - - message = TokenMessage.format(_recipient, _amount); - _validateTokenMessageLength(message); - - return message; } } diff --git a/solidity/test/token/TokenBridgeCctp.t.sol b/solidity/test/token/TokenBridgeCctp.t.sol index aec6991d91..7ed218be20 100644 --- a/solidity/test/token/TokenBridgeCctp.t.sol +++ b/solidity/test/token/TokenBridgeCctp.t.sol @@ -378,13 +378,6 @@ contract TokenBridgeCctpV1Test is Test { bytes memory attestation = bytes(""); bytes memory metadata = abi.encode(cctpMessage, attestation); - // Mock processedAt to return current block number for defense-in-depth check - vm.mockCall( - address(mailboxDestination), - abi.encodeWithSelector(IMailbox.processedAt.selector), - abi.encode(uint48(block.number)) - ); - vm.expectCall( address(messageTransmitterDestination), abi.encodeCall( @@ -395,26 +388,6 @@ contract TokenBridgeCctpV1Test is Test { assertEq(tbDestination.verify(metadata, message), true); } - function test_verify_revertsWhen_messageNotBeingProcessed() public { - ( - bytes memory message, - uint64 cctpNonce, - bytes32 recipient - ) = _setupAndDispatch(); - - bytes memory cctpMessage = _encodeCctpBurnMessage( - cctpNonce, - cctpOrigin, - recipient, - amount - ); - bytes memory attestation = bytes(""); - bytes memory metadata = abi.encode(cctpMessage, attestation); - - vm.expectRevert(bytes("Message not being processed")); - tbDestination.verify(metadata, message); - } - function _upgrade(TokenBridgeCctpBase bridge) internal virtual { TokenBridgeCctpV1 newImplementation = new TokenBridgeCctpV1( address(bridge.wrappedToken()), @@ -450,13 +423,6 @@ contract TokenBridgeCctpV1Test is Test { _upgrade(recipient); - // Mock processedAt to return current block number for defense-in-depth check - vm.mockCall( - address(recipient.mailbox()), - abi.encodeWithSelector(IMailbox.processedAt.selector), - abi.encode(uint48(block.number)) - ); - assert(recipient.verify(metadata, message)); } @@ -478,7 +444,8 @@ contract TokenBridgeCctpV1Test is Test { bytes memory attestation = bytes(""); bytes memory metadata = abi.encode(cctpMessage, attestation); - vm.expectRevert(bytes("Invalid nonce")); + // Nonce validation happens inside Circle's receiveMessage + vm.expectRevert(); tbDestination.verify(metadata, message); } @@ -500,7 +467,8 @@ contract TokenBridgeCctpV1Test is Test { bytes memory attestation = bytes(""); bytes memory metadata = abi.encode(cctpMessage, attestation); - vm.expectRevert(bytes("Invalid source domain")); + // Source domain validation happens inside Circle's receiveMessage + vm.expectRevert(); tbDestination.verify(metadata, message); } @@ -688,11 +656,10 @@ contract TokenBridgeCctpV1Test is Test { vm.expectCall( address(messageTransmitterOrigin), abi.encodeCall( - IRelayer.sendMessageWithCaller, + IRelayer.sendMessage, ( cctpDestination, address(tbDestination).addressToBytes32(), - address(tbDestination).addressToBytes32(), abi.encode(id) ) ) @@ -785,7 +752,13 @@ contract TokenBridgeCctpV1Test is Test { TokenBridgeCctpV1 ism = TokenBridgeCctpV1(router.bytes32ToAddress()); _upgrade(ism); - vm.expectRevert(bytes("Invalid circle sender")); + // Add domain mapping for circle domain 6 -> hyperlane origin domain + // The CCTP message has source domain 6, so we need to configure this mapping + vm.prank(ism.owner()); + ism.addDomain(origin, 6); + + // Sender validation happens inside receiveMessage via callback to _authenticateCircleSender + vm.expectRevert(bytes("Unauthorized circle sender")); ism.verify(metadata, message); // CCTP message was sent by deployer on origin chain @@ -794,13 +767,6 @@ contract TokenBridgeCctpV1Test is Test { vm.prank(ism.owner()); ism.enrollRemoteRouter(origin, deployer.addressToBytes32()); - // Mock processedAt to return current block number for defense-in-depth check - vm.mockCall( - address(ism.mailbox()), - abi.encodeWithSelector(IMailbox.processedAt.selector), - abi.encode(uint48(block.number)) - ); - vm.expectCall( address(ism), abi.encode(TokenBridgeCctpV1.handleReceiveMessage.selector) @@ -913,13 +879,14 @@ contract TokenBridgeCctpV1Test is Test { bytes memory cctpMessage = _encodeCctpHookMessage( badSender, address(tbDestination).addressToBytes32(), - message + abi.encode(Message.id(message)) ); bytes memory attestation = bytes(""); bytes memory metadata = abi.encode(cctpMessage, attestation); - vm.expectRevert(bytes("Invalid circle sender")); + // Sender validation happens inside receiveMessage via callback to _authenticateCircleSender + vm.expectRevert(bytes("Unauthorized circle sender")); tbDestination.verify(metadata, message); } @@ -1164,13 +1131,6 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { memory attestation = hex"fdaca657526b164d6b09678297565d40e1e68cad3bfb0786470b0e8bce013ee340a985970d69629af69599f3deff5cc975b3df46d2efeadfebd867d049e5e5641cba6f5e720dc86c90d8d51747619fbe2b24246e36fa0603792cb86ad88bdc06136663d6211a8d5d134cf94cf8197892a460b24a5e21715642d338530b472a325d1c"; bytes memory metadata = abi.encode(cctpMessage, attestation); - // Mock processedAt to return current block number for defense-in-depth check - vm.mockCall( - address(ism.mailbox()), - abi.encodeWithSelector(IMailbox.processedAt.selector), - abi.encode(uint48(block.number)) - ); - vm.expectCall( address(ism), abi.encode( @@ -1276,13 +1236,6 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { abi.encode(hook, amount) ); - // Mock processedAt to return current block number for defense-in-depth check - vm.mockCall( - address(ism.mailbox()), - abi.encodeWithSelector(IMailbox.processedAt.selector), - abi.encode(uint48(block.number)) - ); - vm.expectEmit(true, true, true, true, address(ism.tokenMessenger())); emit MintAndWithdraw(deployer, amount - fee, usdc, fee); ism.verify(metadata, message); @@ -1444,7 +1397,7 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { ( cctpDestination, address(tbDestination).addressToBytes32(), - address(tbDestination).addressToBytes32(), + address(0).addressToBytes32(), minFinalityThreshold, abi.encode(id) ) From a6ade29c736d50b6e0861b1b8e7fe20b00caee46 Mon Sep 17 00:00:00 2001 From: Yorke Rhodes IV Date: Fri, 17 Oct 2025 18:16:58 -0400 Subject: [PATCH 5/7] Update tests and mocks --- .../mock/MockCircleMessageTransmitter.sol | 20 +- .../contracts/token/TokenBridgeCctpBase.sol | 12 +- .../contracts/token/TokenBridgeCctpV1.sol | 8 +- .../contracts/token/TokenBridgeCctpV2.sol | 12 +- solidity/test/token/TokenBridgeCctp.t.sol | 570 +++++++++++++++--- 5 files changed, 524 insertions(+), 98 deletions(-) diff --git a/solidity/contracts/mock/MockCircleMessageTransmitter.sol b/solidity/contracts/mock/MockCircleMessageTransmitter.sol index de749d2e14..47cf4f1a30 100644 --- a/solidity/contracts/mock/MockCircleMessageTransmitter.sol +++ b/solidity/contracts/mock/MockCircleMessageTransmitter.sol @@ -43,23 +43,37 @@ contract MockCircleMessageTransmitter is ) external returns (bool success) { bytes29 cctpMessage = TypedMemView.ref(message, 0); + // Extract nonce and source domain to check if message was already processed + uint32 sourceDomain; + bytes32 nonceId; + if (version == 0) { + sourceDomain = cctpMessage._sourceDomain(); + uint64 nonce = cctpMessage._nonce(); + nonceId = hashSourceAndNonce(sourceDomain, nonce); + } else { + sourceDomain = cctpMessage._getSourceDomain(); + bytes32 nonce = cctpMessage._getNonce(); + // For V2, use the nonce directly as the nonceId (it's already a bytes32) + nonceId = keccak256(abi.encodePacked(sourceDomain, nonce)); + } + + require(!processedNonces[nonceId], "Message already processed"); + processedNonces[nonceId] = true; + // Extract recipient based on version address recipient; bytes32 sender; - uint32 sourceDomain; bytes memory messageBody; if (version == 0) { // V1 recipient = _bytes32ToAddress(cctpMessage._recipient()); sender = cctpMessage._sender(); - sourceDomain = cctpMessage._sourceDomain(); messageBody = cctpMessage._messageBody().clone(); } else { // V2 recipient = _bytes32ToAddress(cctpMessage._getRecipient()); sender = cctpMessage._getSender(); - sourceDomain = cctpMessage._getSourceDomain(); messageBody = cctpMessage._getMessageBody().clone(); } diff --git a/solidity/contracts/token/TokenBridgeCctpBase.sol b/solidity/contracts/token/TokenBridgeCctpBase.sol index 2aea454219..b6bfeb2d25 100644 --- a/solidity/contracts/token/TokenBridgeCctpBase.sol +++ b/solidity/contracts/token/TokenBridgeCctpBase.sol @@ -278,15 +278,21 @@ abstract contract TokenBridgeCctpBase is return messageTransmitter.receiveMessage(cctpMessageBytes, attestation); } - function _authenticateCircleSender( + function _receiveCircleMessage( uint32 sourceDomain, - bytes32 sender - ) internal view { + bytes32 sender, + bytes calldata body + ) internal returns (bool) { uint32 hyperlaneDomain = circleDomainToHyperlaneDomain(sourceDomain); require( _mustHaveRemoteRouter(hyperlaneDomain) == sender, "Unauthorized circle sender" ); + + // body is abi encoded message ID + preVerifyMessage(bytes32(body[0:32]), 0); + + return true; } function _isAuthorized() internal view override returns (bool) { diff --git a/solidity/contracts/token/TokenBridgeCctpV1.sol b/solidity/contracts/token/TokenBridgeCctpV1.sol index 37df1a7e0d..a0cbd5c8a9 100644 --- a/solidity/contracts/token/TokenBridgeCctpV1.sol +++ b/solidity/contracts/token/TokenBridgeCctpV1.sol @@ -85,13 +85,7 @@ contract TokenBridgeCctpV1 is TokenBridgeCctpBase, IMessageHandler { bytes32 sender, bytes calldata body ) external override returns (bool) { - _authenticateCircleSender(sourceDomain, sender); - preVerifyMessage(_messageId(body), 0); - return true; - } - - function _messageId(bytes calldata body) internal pure returns (bytes32) { - return bytes32(body[0:32]); + return _receiveCircleMessage(sourceDomain, sender, body); } function _sendMessageIdToIsm( diff --git a/solidity/contracts/token/TokenBridgeCctpV2.sol b/solidity/contracts/token/TokenBridgeCctpV2.sol index 9df38cbaae..5c013be564 100644 --- a/solidity/contracts/token/TokenBridgeCctpV2.sol +++ b/solidity/contracts/token/TokenBridgeCctpV2.sol @@ -132,9 +132,7 @@ contract TokenBridgeCctpV2 is TokenBridgeCctpBase, IMessageHandlerV2 { uint32 /*finalityThresholdExecuted*/, bytes calldata messageBody ) external override returns (bool) { - _authenticateCircleSender(sourceDomain, sender); - preVerifyMessage(_messageId(messageBody), 0); - return true; + return _receiveCircleMessage(sourceDomain, sender, messageBody); } // @inheritdoc IMessageHandlerV2 @@ -144,13 +142,7 @@ contract TokenBridgeCctpV2 is TokenBridgeCctpBase, IMessageHandlerV2 { uint32 /*finalityThresholdExecuted*/, bytes calldata messageBody ) external override returns (bool) { - _authenticateCircleSender(sourceDomain, sender); - preVerifyMessage(_messageId(messageBody), 0); - return true; - } - - function _messageId(bytes calldata body) internal pure returns (bytes32) { - return bytes32(body[0:32]); + return _receiveCircleMessage(sourceDomain, sender, messageBody); } function _sendMessageIdToIsm( diff --git a/solidity/test/token/TokenBridgeCctp.t.sol b/solidity/test/token/TokenBridgeCctp.t.sol index 7ed218be20..55006b04ee 100644 --- a/solidity/test/token/TokenBridgeCctp.t.sol +++ b/solidity/test/token/TokenBridgeCctp.t.sol @@ -426,52 +426,6 @@ contract TokenBridgeCctpV1Test is Test { assert(recipient.verify(metadata, message)); } - function test_verify_revertsWhen_invalidNonce() public virtual { - ( - bytes memory message, - uint64 cctpNonce, - bytes32 recipient - ) = _setupAndDispatch(); - - // invalid nonce := nextNonce + 1 - uint64 badNonce = cctpNonce + 1; - bytes memory cctpMessage = _encodeCctpBurnMessage( - badNonce, - cctpOrigin, - recipient, - amount - ); - bytes memory attestation = bytes(""); - bytes memory metadata = abi.encode(cctpMessage, attestation); - - // Nonce validation happens inside Circle's receiveMessage - vm.expectRevert(); - tbDestination.verify(metadata, message); - } - - function test_verify_revertsWhen_invalidSourceDomain() public { - ( - bytes memory message, - uint64 cctpNonce, - bytes32 recipient - ) = _setupAndDispatch(); - - // invalid source domain := destination - uint32 badSourceDomain = cctpDestination; - bytes memory cctpMessage = _encodeCctpBurnMessage( - cctpNonce, - badSourceDomain, - recipient, - amount - ); - bytes memory attestation = bytes(""); - bytes memory metadata = abi.encode(cctpMessage, attestation); - - // Source domain validation happens inside Circle's receiveMessage - vm.expectRevert(); - tbDestination.verify(metadata, message); - } - function test_verify_revertsWhen_invalidMintAmount() public { ( bytes memory message, @@ -534,29 +488,6 @@ contract TokenBridgeCctpV1Test is Test { tbDestination.verify(metadata, message); } - function test_verify_revertsWhen_invalidLength() public { - ( - bytes memory message, - uint64 cctpNonce, - bytes32 recipient - ) = _setupAndDispatch(); - - bytes memory cctpMessage = _encodeCctpBurnMessage( - cctpNonce, - cctpOrigin, - recipient, - amount - ); - bytes memory attestation = bytes(""); - bytes memory metadata = abi.encode(cctpMessage, attestation); - - // a message with invalid length. - bytes memory badMessage = bytes.concat(message, bytes1(uint8(60))); - - vm.expectRevert(); - tbDestination.verify(metadata, badMessage); - } - function test_revertsWhen_versionIsNotSupported() public virtual { tokenMessengerOrigin.setVersion(CCTP_VERSION_2); @@ -692,6 +623,11 @@ contract TokenBridgeCctpV1Test is Test { uint32 origin = mailbox.localDomain(); bytes32 router = hook.routers(destination); + // Ensure domain mapping exists + uint32 circleDestination = 0; // ethereum circle domain + vm.prank(hook.owner()); + hook.addDomain(destination, circleDestination); + // precompute message ID bytes memory message = Message.formatMessage( 3, @@ -710,7 +646,7 @@ contract TokenBridgeCctpV1Test is Test { hook.messageTransmitter().nextAvailableNonce(), address(hook).addressToBytes32(), router, - router, + bytes32(0), abi.encode(Message.id(message)) ); @@ -943,6 +879,166 @@ contract TokenBridgeCctpV1Test is Test { vm.expectRevert(bytes("Invalid circle recipient")); tbDestination.verify(metadata, message); } + + // ============ handleReceiveMessage Tests (V1 only) ============ + + function test_handleReceiveMessage(bytes calldata message) public virtual { + bytes32 messageId = Message.id(message); + // Call handleReceiveMessage from the message transmitter + vm.prank(address(messageTransmitterDestination)); + bool result = TokenBridgeCctpV1(address(tbDestination)) + .handleReceiveMessage( + cctpOrigin, + address(tbOrigin).addressToBytes32(), + abi.encode(messageId) + ); + + assertTrue(result); + assertTrue( + TokenBridgeCctpBase(address(tbDestination)).isVerified(message) + ); + } + + function test_handleReceiveMessage_revertsWhen_unauthorizedSender( + bytes32 messageId + ) public virtual { + // Try to call from an unauthorized address + vm.prank(address(messageTransmitterDestination)); + vm.expectRevert(bytes("Unauthorized circle sender")); + TokenBridgeCctpV1(address(tbDestination)).handleReceiveMessage( + cctpOrigin, + evil.addressToBytes32(), + abi.encode(messageId) + ); + } + + function test_handleReceiveMessage_revertsWhen_unauthorizedCaller( + bytes32 messageId + ) public virtual { + // Try to call from a non-message-transmitter address + vm.prank(evil); + vm.expectRevert( + bytes("AbstractMessageIdAuthorizedIsm: sender is not the hook") + ); + TokenBridgeCctpV1(address(tbDestination)).handleReceiveMessage( + cctpOrigin, + address(tbOrigin).addressToBytes32(), + abi.encode(messageId) + ); + } + + function test_handleReceiveMessage_revertsWhen_unconfiguredDomain( + uint32 badDomain, + bytes32 messageId + ) public virtual { + // Assume the domain is not configured + vm.assume(badDomain != cctpOrigin); + vm.assume(badDomain != cctpDestination); + + vm.prank(address(messageTransmitterDestination)); + vm.expectRevert(bytes("Hyperlane domain not configured")); + TokenBridgeCctpV1(address(tbDestination)).handleReceiveMessage( + badDomain, + address(tbOrigin).addressToBytes32(), + abi.encode(messageId) + ); + } + + function test_handleReceiveMessage_revertsWhen_unenrolledRouter( + bytes32 badRouter, + bytes32 messageId + ) public virtual { + // Assume the router is different from the enrolled one + vm.assume(badRouter != address(tbOrigin).addressToBytes32()); + + vm.prank(address(messageTransmitterDestination)); + vm.expectRevert(bytes("Unauthorized circle sender")); + TokenBridgeCctpV1(address(tbDestination)).handleReceiveMessage( + cctpOrigin, + badRouter, + abi.encode(messageId) + ); + } + + function test_handleReceiveMessage_revertsWhen_messageAlreadyDelivered( + bytes32 messageId + ) public virtual { + // First delivery succeeds + vm.prank(address(messageTransmitterDestination)); + TokenBridgeCctpV1(address(tbDestination)).handleReceiveMessage( + cctpOrigin, + address(tbOrigin).addressToBytes32(), + abi.encode(messageId) + ); + + // Second delivery of the same message should revert + vm.prank(address(messageTransmitterDestination)); + vm.expectRevert( + bytes("AbstractMessageIdAuthorizedIsm: message already verified") + ); + TokenBridgeCctpV1(address(tbDestination)).handleReceiveMessage( + cctpOrigin, + address(tbOrigin).addressToBytes32(), + abi.encode(messageId) + ); + } + + function test_verify_returnsTrue_afterDirectDelivery( + bytes calldata message + ) public virtual { + bytes32 messageId = Message.id(message); + + // First, deliver the message directly via handleReceiveMessage + vm.prank(address(messageTransmitterDestination)); + assertTrue( + TokenBridgeCctpV1(address(tbDestination)).handleReceiveMessage( + cctpOrigin, + address(tbOrigin).addressToBytes32(), + abi.encode(messageId) + ) + ); + + // Verify the message is marked as verified + assertTrue( + TokenBridgeCctpBase(address(tbDestination)).isVerified(message) + ); + + // Now call verify with empty metadata - should return true without attestation + bytes memory metadata = abi.encode(bytes(""), bytes("")); + assertTrue(tbDestination.verify(metadata, message)); + } + + function test_verify_revertsWhen_tokenMessageAlreadyDelivered() + public + virtual + { + // Setup a token transfer + ( + bytes memory message, + uint64 cctpNonce, + bytes32 recipient + ) = _setupAndDispatch(); + + // Create CCTP message for the token transfer + bytes memory cctpMessage = _encodeCctpBurnMessage( + cctpNonce, + cctpOrigin, + recipient, + amount + ); + + // Deliver the CCTP message directly via receiveMessage (simulates CCTP delivering the burn message) + // This mints the tokens to the recipient + messageTransmitterDestination.receiveMessage(cctpMessage, bytes("")); + + // Now try to verify with the same message - should revert because CCTP already processed it + bytes memory metadata = abi.encode(cctpMessage, bytes("")); + + // The exact revert message depends on the mock implementation + // In a real scenario, Circle's MessageTransmitter would revert with a nonce already used error + vm.expectRevert(); + tbDestination.verify(metadata, message); + } } contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { @@ -1274,7 +1370,7 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { hook.hyperlaneDomainToCircleDomain(destination), address(hook).addressToBytes32(), ism, - ism, + bytes32(0), minFinalityThreshold, abi.encode(Message.id(message)) ); @@ -1438,10 +1534,10 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { ); } - function test_verify_revertsWhen_invalidNonce() public override { - vm.skip(true); - // cannot assert nonce in v2 - } + // function test_verify_revertsWhen_invalidNonce() public override { + // vm.skip(true); + // // cannot assert nonce in v2 + // } function testFork_verify_upgrade() public override { vm.skip(true); @@ -1466,4 +1562,328 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { assertEq(quotes[2].token, address(tokenOrigin)); assertEq(quotes[2].amount, fastFee); } + + // ============ Override V1 handleReceiveMessage tests (V2 doesn't have this function) ============ + + // function test_handleReceiveMessage(bytes32) public pure override { + // // V2 doesn't have handleReceiveMessage, skip this test + // } + + function test_handleReceiveMessage_revertsWhen_unauthorizedSender( + bytes32 + ) public pure override { + // V2 doesn't have handleReceiveMessage, skip this test + } + + function test_handleReceiveMessage_revertsWhen_unauthorizedCaller( + bytes32 + ) public pure override { + // V2 doesn't have handleReceiveMessage, skip this test + } + + function test_handleReceiveMessage_revertsWhen_unconfiguredDomain( + uint32, + bytes32 + ) public pure override { + // V2 doesn't have handleReceiveMessage, skip this test + } + + function test_handleReceiveMessage_revertsWhen_unenrolledRouter( + bytes32, + bytes32 + ) public pure override { + // V2 doesn't have handleReceiveMessage, skip this test + } + + // ============ handleReceiveFinalizedMessage Tests (V2 only) ============ + + function test_handleReceiveMessage(bytes calldata message) public override { + uint32 finalityThreshold = 2000; + bytes32 messageId = Message.id(message); + // Call handleReceiveFinalizedMessage from the message transmitter + vm.prank(address(messageTransmitterDestination)); + bool result = TokenBridgeCctpV2(address(tbDestination)) + .handleReceiveFinalizedMessage( + cctpOrigin, + address(tbOrigin).addressToBytes32(), + finalityThreshold, + abi.encode(messageId) + ); + + assertTrue(result); + assertTrue( + TokenBridgeCctpBase(address(tbDestination)).isVerified(message) + ); + } + + function test_handleReceiveFinalizedMessage_revertsWhen_unauthorizedSender( + bytes32 messageId, + uint32 finalityThreshold + ) public { + vm.prank(address(messageTransmitterDestination)); + vm.expectRevert(bytes("Unauthorized circle sender")); + TokenBridgeCctpV2(address(tbDestination)).handleReceiveFinalizedMessage( + cctpOrigin, + evil.addressToBytes32(), + finalityThreshold, + abi.encode(messageId) + ); + } + + function test_handleReceiveFinalizedMessage_revertsWhen_unauthorizedCaller( + bytes32 messageId, + uint32 finalityThreshold + ) public { + vm.prank(evil); + vm.expectRevert( + bytes("AbstractMessageIdAuthorizedIsm: sender is not the hook") + ); + TokenBridgeCctpV2(address(tbDestination)).handleReceiveFinalizedMessage( + cctpOrigin, + address(tbOrigin).addressToBytes32(), + finalityThreshold, + abi.encode(messageId) + ); + } + + function test_handleReceiveFinalizedMessage_revertsWhen_unconfiguredDomain( + uint32 badDomain, + bytes32 messageId, + uint32 finalityThreshold + ) public { + vm.assume(badDomain != cctpOrigin); + vm.assume(badDomain != cctpDestination); + + vm.prank(address(messageTransmitterDestination)); + vm.expectRevert(bytes("Hyperlane domain not configured")); + TokenBridgeCctpV2(address(tbDestination)).handleReceiveFinalizedMessage( + badDomain, + address(tbOrigin).addressToBytes32(), + finalityThreshold, + abi.encode(messageId) + ); + } + + function test_handleReceiveFinalizedMessage_revertsWhen_unenrolledRouter( + bytes32 badRouter, + bytes32 messageId, + uint32 finalityThreshold + ) public { + vm.assume(badRouter != address(tbOrigin).addressToBytes32()); + + vm.prank(address(messageTransmitterDestination)); + vm.expectRevert(bytes("Unauthorized circle sender")); + TokenBridgeCctpV2(address(tbDestination)).handleReceiveFinalizedMessage( + cctpOrigin, + badRouter, + finalityThreshold, + abi.encode(messageId) + ); + } + + // ============ handleReceiveUnfinalizedMessage Tests (V2 only) ============ + + function test_handleReceiveUnfinalizedMessage( + bytes calldata message, + uint32 finalityThreshold + ) public { + bytes32 messageId = Message.id(message); + // Call handleReceiveUnfinalizedMessage from the message transmitter + vm.prank(address(messageTransmitterDestination)); + bool result = TokenBridgeCctpV2(address(tbDestination)) + .handleReceiveUnfinalizedMessage( + cctpOrigin, + address(tbOrigin).addressToBytes32(), + finalityThreshold, + abi.encode(messageId) + ); + + assertTrue(result); + assertTrue( + TokenBridgeCctpBase(address(tbDestination)).isVerified(message) + ); + } + + function test_handleReceiveUnfinalizedMessage_revertsWhen_unauthorizedSender( + bytes32 messageId, + uint32 finalityThreshold + ) public { + vm.prank(address(messageTransmitterDestination)); + vm.expectRevert(bytes("Unauthorized circle sender")); + TokenBridgeCctpV2(address(tbDestination)) + .handleReceiveUnfinalizedMessage( + cctpOrigin, + evil.addressToBytes32(), + finalityThreshold, + abi.encode(messageId) + ); + } + + function test_handleReceiveUnfinalizedMessage_revertsWhen_unauthorizedCaller( + bytes32 messageId, + uint32 finalityThreshold + ) public { + vm.prank(evil); + vm.expectRevert( + bytes("AbstractMessageIdAuthorizedIsm: sender is not the hook") + ); + TokenBridgeCctpV2(address(tbDestination)) + .handleReceiveUnfinalizedMessage( + cctpOrigin, + address(tbOrigin).addressToBytes32(), + finalityThreshold, + abi.encode(messageId) + ); + } + + function test_handleReceiveUnfinalizedMessage_revertsWhen_unconfiguredDomain( + uint32 badDomain, + bytes32 messageId, + uint32 finalityThreshold + ) public { + vm.assume(badDomain != cctpOrigin); + vm.assume(badDomain != cctpDestination); + + vm.prank(address(messageTransmitterDestination)); + vm.expectRevert(bytes("Hyperlane domain not configured")); + TokenBridgeCctpV2(address(tbDestination)) + .handleReceiveUnfinalizedMessage( + badDomain, + address(tbOrigin).addressToBytes32(), + finalityThreshold, + abi.encode(messageId) + ); + } + + function test_handleReceiveUnfinalizedMessage_revertsWhen_unenrolledRouter( + bytes32 badRouter, + bytes32 messageId, + uint32 finalityThreshold + ) public { + vm.assume(badRouter != address(tbOrigin).addressToBytes32()); + + vm.prank(address(messageTransmitterDestination)); + vm.expectRevert(bytes("Unauthorized circle sender")); + TokenBridgeCctpV2(address(tbDestination)) + .handleReceiveUnfinalizedMessage( + cctpOrigin, + badRouter, + finalityThreshold, + abi.encode(messageId) + ); + } + + function test_handleReceiveMessage_revertsWhen_messageAlreadyDelivered( + bytes32 messageId + ) public override { + uint32 finalityThreshold = 2000; + // First delivery succeeds + vm.prank(address(messageTransmitterDestination)); + TokenBridgeCctpV2(address(tbDestination)).handleReceiveFinalizedMessage( + cctpOrigin, + address(tbOrigin).addressToBytes32(), + finalityThreshold, + abi.encode(messageId) + ); + + // Second delivery of the same message should revert + vm.prank(address(messageTransmitterDestination)); + vm.expectRevert( + bytes("AbstractMessageIdAuthorizedIsm: message already verified") + ); + TokenBridgeCctpV2(address(tbDestination)).handleReceiveFinalizedMessage( + cctpOrigin, + address(tbOrigin).addressToBytes32(), + finalityThreshold, + abi.encode(messageId) + ); + } + + function test_verify_returnsTrue_afterDirectDelivery( + bytes calldata message + ) public override { + bytes32 messageId = Message.id(message); + uint32 finalityThreshold = 2000; + + // First, deliver the message directly via handleReceiveFinalizedMessage + vm.prank(address(messageTransmitterDestination)); + assertTrue( + TokenBridgeCctpV2(address(tbDestination)) + .handleReceiveFinalizedMessage( + cctpOrigin, + address(tbOrigin).addressToBytes32(), + finalityThreshold, + abi.encode(messageId) + ) + ); + + // Verify the message is marked as verified + assertTrue( + TokenBridgeCctpBase(address(tbDestination)).isVerified(message) + ); + + // Now call verify with empty metadata - should return true without attestation + bytes memory metadata = abi.encode(bytes(""), bytes("")); + assertTrue(tbDestination.verify(metadata, message)); + } + + function test_verify_returnsTrue_afterUnfinalizedDirectDelivery( + bytes calldata message + ) public { + bytes32 messageId = Message.id(message); + uint32 finalityThreshold = 1500; + + // First, deliver the message directly via handleReceiveUnfinalizedMessage + vm.prank(address(messageTransmitterDestination)); + assertTrue( + TokenBridgeCctpV2(address(tbDestination)) + .handleReceiveUnfinalizedMessage( + cctpOrigin, + address(tbOrigin).addressToBytes32(), + finalityThreshold, + abi.encode(messageId) + ) + ); + + // Verify the message is marked as verified + assertTrue( + TokenBridgeCctpBase(address(tbDestination)).isVerified(message) + ); + + // Now call verify with empty metadata - should return true without attestation + bytes memory metadata = abi.encode(bytes(""), bytes("")); + assertTrue(tbDestination.verify(metadata, message)); + } + + function test_verify_revertsWhen_tokenMessageAlreadyDelivered() + public + override + { + // Setup a token transfer + ( + bytes memory message, + uint64 cctpNonce, + bytes32 recipient + ) = _setupAndDispatch(); + + // Create CCTP V2 message for the token transfer + bytes memory cctpMessage = _encodeCctpBurnMessage( + cctpNonce, + cctpOrigin, + recipient, + amount + ); + + // Deliver the CCTP message directly via receiveMessage (simulates CCTP delivering the burn message) + // This mints the tokens to the recipient + messageTransmitterDestination.receiveMessage(cctpMessage, bytes("")); + + // Now try to verify with the same message - should revert because CCTP already processed it + bytes memory metadata = abi.encode(cctpMessage, bytes("")); + + // The exact revert message depends on the mock implementation + // In a real scenario, Circle's MessageTransmitter would revert with a nonce already used error + vm.expectRevert(); + tbDestination.verify(metadata, message); + } } From 93ff52452afd99cd5306f61b54114ac2c9ef68e3 Mon Sep 17 00:00:00 2001 From: Yorke Rhodes IV Date: Mon, 20 Oct 2025 11:25:39 -0400 Subject: [PATCH 6/7] Improve docs and comments --- solidity/contracts/token/CCTP.md | 101 ++++++++++++++++++ .../contracts/token/TokenBridgeCctpBase.sol | 19 ++-- .../contracts/token/TokenBridgeCctpV1.sol | 7 +- .../contracts/token/TokenBridgeCctpV2.sol | 14 ++- 4 files changed, 130 insertions(+), 11 deletions(-) diff --git a/solidity/contracts/token/CCTP.md b/solidity/contracts/token/CCTP.md index 49deb1897c..0cc886077d 100644 --- a/solidity/contracts/token/CCTP.md +++ b/solidity/contracts/token/CCTP.md @@ -107,3 +107,104 @@ flowchart LR class MT_O,MT_D,TM_O,TM_D,Iris,USDC_O,USDC_D cctp class M_O,M_D,Relayer hyperlane ``` + +## Destination Chain Sequence Diagrams + +### 1. Token Message with Hyperlane Relayer + +```mermaid +sequenceDiagram + participant HR as Hyperlane Relayer + participant Mailbox as Mailbox + participant TBCCTP as TokenBridgeCctp + participant MT as MessageTransmitter + participant TM as TokenMessenger + participant USDC as USDC + participant Recipient as Recipient + + HR->>Mailbox: process([burnMessage, attestation], tokenMessage) + Mailbox->>TBCCTP: verify([burnMessage, attestation], tokenMessage) + TBCCTP->>MT: receiveMessage(burnMessage, attestation) + MT->>TM: handleReceiveMessage(burnMessage) + TM->>USDC: mint(amount, recipient) + USDC-->>Recipient: amount transferred + + Note over Mailbox: Marks message as delivered
in delivered mapping + Mailbox->>TBCCTP: handle(tokenMessage) + TBCCTP-->>Recipient: emit event reflecting tokens were transferred +``` + +### 2. Token Message with CCTP Relayer and Hyperlane Relayer + +```mermaid +sequenceDiagram + participant CR as CCTP Relayer + participant HR as Hyperlane Relayer + participant Mailbox as Mailbox + participant TBCCTP as TokenBridgeCctp + participant MT as MessageTransmitter + participant TM as TokenMessenger + participant USDC as USDC + participant Recipient as Recipient + + Note over CR: CCTP Relayer submits
burn message first + CR->>TBCCTP: receiveMessage(burnMessage, attestation) + TBCCTP->>MT: receiveMessage(burnMessage, attestation) + MT->>TM: handleReceiveMessage(burnMessage) + TM->>USDC: mint(amount, recipient) + USDC-->>Recipient: amount minted + + Note over HR: Hyperlane Relayer delivers
token message + HR->>Mailbox: process([], tokenMessage) + Mailbox->>TBCCTP: verify([], tokenMessage) + TBCCTP-xMailbox: REVERT: Burn message already processed + Note over Mailbox: Transaction reverts,
handle never called +``` + +### 3. GMP Message with Hyperlane Relayer + +```mermaid +sequenceDiagram + participant HR as Hyperlane Relayer + participant Mailbox as Mailbox + participant TBCCTP as TokenBridgeCctp (ISM) + participant MT as MessageTransmitter + participant Recipient as Recipient App + + HR->>Mailbox: process([cctpMessage, attestation], hyperlaneMessage) + Mailbox->>TBCCTP: verify([cctpMessage, attestation], hyperlaneMessage) + TBCCTP->>MT: receiveMessage(cctpMessage, attestation) + MT->>TBCCTP: handleReceiveMessage(cctpMessage) + Note over TBCCTP: Verifies message ID matches + + Note over Mailbox: Marks message as delivered
in delivered mapping + Mailbox->>Recipient: handle(hyperlaneMessage) + Note over Recipient: Application receives message +``` + +### 4. GMP Message with CCTP Relayer and Hyperlane Relayer + +```mermaid +sequenceDiagram + participant CR as CCTP Relayer + participant HR as Hyperlane Relayer + participant Mailbox as Mailbox + participant TBCCTP as TokenBridgeCctp (ISM) + participant MT as MessageTransmitter + participant Recipient as Recipient App + + Note over CR: CCTP Relayer submits
message first + CR->>TBCCTP: receiveMessage(cctpMessage, attestation) + TBCCTP->>MT: receiveMessage(cctpMessage, attestation) + MT->>TBCCTP: handleReceiveMessage(cctpMessage) + Note over TBCCTP: Stores message ID + + Note over HR: Hyperlane Relayer delivers
GMP message + HR->>Mailbox: process([], hyperlaneMessage) + Mailbox->>TBCCTP: verify([], hyperlaneMessage) + Note over TBCCTP: CCTP message already processed,
verifies message ID + + Note over Mailbox: Marks message as delivered
in delivered mapping + Mailbox->>Recipient: handle(hyperlaneMessage) + Note over Recipient: Application receives message +``` diff --git a/solidity/contracts/token/TokenBridgeCctpBase.sol b/solidity/contracts/token/TokenBridgeCctpBase.sol index b6bfeb2d25..b415f33a03 100644 --- a/solidity/contracts/token/TokenBridgeCctpBase.sol +++ b/solidity/contracts/token/TokenBridgeCctpBase.sol @@ -36,6 +36,7 @@ abstract contract TokenBridgeCctpBaseStorage is TokenRouter { MovableCollateralRouterStorage private __MOVABLE_COLLATERAL_GAP; } +// see ./CCTP.md for sequence diagrams of the destination chain control flow abstract contract TokenBridgeCctpBase is TokenBridgeCctpBaseStorage, AbstractCcipReadIsm, @@ -275,22 +276,24 @@ abstract contract TokenBridgeCctpBase is revert("Invalid circle recipient"); } + // for GMP messages, this.verifiedMessages[hyperlaneMessage.id()] will be set + // for token messages, hyperlaneMessage.body().amount() tokens will be delivered to hyperlaneMessage.body().recipient() return messageTransmitter.receiveMessage(cctpMessageBytes, attestation); } - function _receiveCircleMessage( - uint32 sourceDomain, - bytes32 sender, - bytes calldata body + function _receiveMessageId( + uint32 circleSource, + bytes32 circleSender, + bytes32 messageId ) internal returns (bool) { - uint32 hyperlaneDomain = circleDomainToHyperlaneDomain(sourceDomain); + // ensure that the message was sent from the hook on the origin chain + uint32 origin = circleDomainToHyperlaneDomain(circleSource); require( - _mustHaveRemoteRouter(hyperlaneDomain) == sender, + _mustHaveRemoteRouter(origin) == circleSender, "Unauthorized circle sender" ); - // body is abi encoded message ID - preVerifyMessage(bytes32(body[0:32]), 0); + preVerifyMessage(messageId, 0); return true; } diff --git a/solidity/contracts/token/TokenBridgeCctpV1.sol b/solidity/contracts/token/TokenBridgeCctpV1.sol index a0cbd5c8a9..92411db53a 100644 --- a/solidity/contracts/token/TokenBridgeCctpV1.sol +++ b/solidity/contracts/token/TokenBridgeCctpV1.sol @@ -85,7 +85,12 @@ contract TokenBridgeCctpV1 is TokenBridgeCctpBase, IMessageHandler { bytes32 sender, bytes calldata body ) external override returns (bool) { - return _receiveCircleMessage(sourceDomain, sender, body); + return + _receiveMessageId( + sourceDomain, + sender, + abi.decode(body, (bytes32)) + ); } function _sendMessageIdToIsm( diff --git a/solidity/contracts/token/TokenBridgeCctpV2.sol b/solidity/contracts/token/TokenBridgeCctpV2.sol index 5c013be564..5941061139 100644 --- a/solidity/contracts/token/TokenBridgeCctpV2.sol +++ b/solidity/contracts/token/TokenBridgeCctpV2.sol @@ -132,7 +132,12 @@ contract TokenBridgeCctpV2 is TokenBridgeCctpBase, IMessageHandlerV2 { uint32 /*finalityThresholdExecuted*/, bytes calldata messageBody ) external override returns (bool) { - return _receiveCircleMessage(sourceDomain, sender, messageBody); + return + _receiveMessageId( + sourceDomain, + sender, + abi.decode(messageBody, (bytes32)) + ); } // @inheritdoc IMessageHandlerV2 @@ -142,7 +147,12 @@ contract TokenBridgeCctpV2 is TokenBridgeCctpBase, IMessageHandlerV2 { uint32 /*finalityThresholdExecuted*/, bytes calldata messageBody ) external override returns (bool) { - return _receiveCircleMessage(sourceDomain, sender, messageBody); + return + _receiveMessageId( + sourceDomain, + sender, + abi.decode(messageBody, (bytes32)) + ); } function _sendMessageIdToIsm( From 8250b2a465fae5007f0ab3399e36c0e6aee6417c Mon Sep 17 00:00:00 2001 From: Yorke Rhodes IV Date: Mon, 20 Oct 2025 11:36:28 -0400 Subject: [PATCH 7/7] Fix storage compatibility and upgrade tests --- .../contracts/token/TokenBridgeCctpBase.sol | 33 +++++++++++-------- solidity/test/token/TokenBridgeCctp.t.sol | 5 --- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/solidity/contracts/token/TokenBridgeCctpBase.sol b/solidity/contracts/token/TokenBridgeCctpBase.sol index b415f33a03..7a0a33c1d4 100644 --- a/solidity/contracts/token/TokenBridgeCctpBase.sol +++ b/solidity/contracts/token/TokenBridgeCctpBase.sol @@ -36,11 +36,26 @@ abstract contract TokenBridgeCctpBaseStorage is TokenRouter { MovableCollateralRouterStorage private __MOVABLE_COLLATERAL_GAP; } -// see ./CCTP.md for sequence diagrams of the destination chain control flow -abstract contract TokenBridgeCctpBase is +struct Domain { + uint32 hyperlane; + uint32 circle; +} + +// need intermediate contract to insert slots between TokenBridgeCctpBase and AbstractMessageIdAuthorizedIsm +abstract contract TokenBridgeCctpIntermediateStorage is TokenBridgeCctpBaseStorage, AbstractCcipReadIsm, - AbstractPostDispatchHook, + AbstractPostDispatchHook +{ + /// @notice Hyperlane domain => Domain struct. + /// We use a struct to avoid ambiguity with domain 0 being unknown. + mapping(uint32 hypDomain => Domain circleDomain) + internal _hyperlaneDomainMap; +} + +// see ./CCTP.md for sequence diagrams of the destination chain control flow +abstract contract TokenBridgeCctpBase is + TokenBridgeCctpIntermediateStorage, AbstractMessageIdAuthorizedIsm { using Message for bytes; @@ -57,18 +72,10 @@ abstract contract TokenBridgeCctpBase is // @notice CCTP token messenger contract ITokenMessenger public immutable tokenMessenger; - struct Domain { - uint32 hyperlane; - uint32 circle; - } - - /// @notice Hyperlane domain => Domain struct. - /// We use a struct to avoid ambiguity with domain 0 being unknown. - mapping(uint32 hypDomain => Domain domain) internal _hyperlaneDomainMap; - /// @notice Circle domain => Domain struct. // We use a struct to avoid ambiguity with domain 0 being unknown. - mapping(uint32 circleDomain => Domain domain) internal _circleDomainMap; + mapping(uint32 circleDomain => Domain hyperlaneDomain) + internal _circleDomainMap; /** * @notice Emitted when the Hyperlane domain to Circle domain mapping is updated. diff --git a/solidity/test/token/TokenBridgeCctp.t.sol b/solidity/test/token/TokenBridgeCctp.t.sol index 55006b04ee..e4e3b5b9b9 100644 --- a/solidity/test/token/TokenBridgeCctp.t.sol +++ b/solidity/test/token/TokenBridgeCctp.t.sol @@ -688,11 +688,6 @@ contract TokenBridgeCctpV1Test is Test { TokenBridgeCctpV1 ism = TokenBridgeCctpV1(router.bytes32ToAddress()); _upgrade(ism); - // Add domain mapping for circle domain 6 -> hyperlane origin domain - // The CCTP message has source domain 6, so we need to configure this mapping - vm.prank(ism.owner()); - ism.addDomain(origin, 6); - // Sender validation happens inside receiveMessage via callback to _authenticateCircleSender vm.expectRevert(bytes("Unauthorized circle sender")); ism.verify(metadata, message);