diff --git a/solidity/contracts/mock/MockCircleMessageTransmitter.sol b/solidity/contracts/mock/MockCircleMessageTransmitter.sol index fa35f16363..47cf4f1a30 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,66 @@ 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 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; + bytes memory messageBody; + + if (version == 0) { + // V1 + recipient = _bytes32ToAddress(cctpMessage._recipient()); + sender = cctpMessage._sender(); + messageBody = cctpMessage._messageBody().clone(); + } else { + // V2 + recipient = _bytes32ToAddress(cctpMessage._getRecipient()); + sender = cctpMessage._getSender(); + 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 +138,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 +183,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/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 0d9b000e81..7a0a33c1d4 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"; @@ -36,10 +36,27 @@ abstract contract TokenBridgeCctpBaseStorage is TokenRouter { MovableCollateralRouterStorage private __MOVABLE_COLLATERAL_GAP; } -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 +{ + /// @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; using TypeCasts for bytes32; @@ -55,14 +72,10 @@ abstract contract TokenBridgeCctpBase is // @notice CCTP token messenger contract ITokenMessenger public immutable tokenMessenger; - struct Domain { - uint32 hyperlane; - uint32 circle; - } - - /// @notice Hyperlane domain => Circle domain. - /// We use a struct to avoid ambiguity with domain 0 being unknown. - mapping(uint32 hypDomain => Domain circleDomain) internal _domainMap; + /// @notice Circle domain => Domain struct. + // We use a struct to avoid ambiguity with domain 0 being unknown. + mapping(uint32 circleDomain => Domain hyperlaneDomain) + internal _circleDomainMap; /** * @notice Emitted when the Hyperlane domain to Circle domain mapping is updated. @@ -136,12 +149,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 +182,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 +203,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,20 +212,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 _getCircleNonce( - bytes29 cctpMessage - ) internal pure virtual returns (bytes32); - function _validateTokenMessage( bytes calldata hyperlaneMessage, bytes29 cctpMessage @@ -216,7 +238,7 @@ abstract contract TokenBridgeCctpBase is function _validateHookMessage( bytes calldata hyperlaneMessage, bytes29 cctpMessage - ) internal view virtual; + ) internal pure virtual; function _sendMessageIdToIsm( uint32 destinationDomain, @@ -224,11 +246,22 @@ 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. + */ 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, @@ -236,15 +269,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)) { @@ -259,21 +283,32 @@ 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" - ); - } + // 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 _receiveMessageId( + uint32 circleSource, + bytes32 circleSender, + bytes32 messageId + ) internal returns (bool) { + // ensure that the message was sent from the hook on the origin chain + uint32 origin = circleDomainToHyperlaneDomain(circleSource); + require( + _mustHaveRemoteRouter(origin) == circleSender, + "Unauthorized circle sender" + ); + + preVerifyMessage(messageId, 0); return true; } + function _isAuthorized() internal view override returns (bool) { + return msg.sender == address(messageTransmitter); + } + function _offchainLookupCalldata( bytes calldata _message ) internal pure override returns (bytes memory) { @@ -294,6 +329,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 @@ -333,5 +370,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 41749665df..92411db53a 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,21 +44,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) { - return cctpMessage._sourceDomain(); - } - function _validateTokenMessage( bytes calldata hyperlaneMessage, bytes29 cctpMessage @@ -77,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(), @@ -100,24 +74,23 @@ 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) { - return true; + uint32 sourceDomain, + bytes32 sender, + bytes calldata body + ) external override returns (bool) { + return + _receiveMessageId( + sourceDomain, + sender, + abi.decode(body, (bytes32)) + ); } function _sendMessageIdToIsm( @@ -125,43 +98,23 @@ contract TokenBridgeCctpV1 is TokenBridgeCctpBase, IMessageHandler { 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 021d01168d..5941061139 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,27 +90,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) { - 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 @@ -128,7 +104,6 @@ contract TokenBridgeCctpV2 is TokenBridgeCctpBase, IMessageHandlerV2 { ); bytes calldata tokenMessage = hyperlaneMessage.body(); - _validateTokenMessageLength(tokenMessage); require( TokenMessage.amount(tokenMessage) == burnMessage._getAmount(), @@ -145,35 +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) { - return true; + bytes calldata messageBody + ) external override returns (bool) { + return + _receiveMessageId( + sourceDomain, + sender, + abi.decode(messageBody, (bytes32)) + ); } // @inheritdoc IMessageHandlerV2 function handleReceiveUnfinalizedMessage( - uint32 /*sourceDomain*/, - bytes32 /*sender*/, + uint32 sourceDomain, + bytes32 sender, uint32 /*finalityThresholdExecuted*/, - bytes calldata /*messageBody*/ - ) external pure override returns (bool) { - return true; + bytes calldata messageBody + ) external override returns (bool) { + return + _receiveMessageId( + sourceDomain, + sender, + abi.decode(messageBody, (bytes32)) + ); } function _sendMessageIdToIsm( @@ -184,7 +163,7 @@ contract TokenBridgeCctpV2 is TokenBridgeCctpBase, IMessageHandlerV2 { IMessageTransmitterV2(address(messageTransmitter)).sendMessage( destinationDomain, ism, - ism, + bytes32(0), // allow anyone to relay minFinalityThreshold, abi.encode(messageId) ); @@ -194,7 +173,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, @@ -204,10 +183,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 67264ad1ae..e4e3b5b9b9 100644 --- a/solidity/test/token/TokenBridgeCctp.t.sol +++ b/solidity/test/token/TokenBridgeCctp.t.sol @@ -422,51 +422,8 @@ contract TokenBridgeCctpV1Test is Test { recipient.verify(metadata, message); _upgrade(recipient); - 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); - - vm.expectRevert(bytes("Invalid nonce")); - 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); - - vm.expectRevert(bytes("Invalid source domain")); - tbDestination.verify(metadata, message); + assert(recipient.verify(metadata, message)); } function test_verify_revertsWhen_invalidMintAmount() public { @@ -531,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); @@ -653,11 +587,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) ) ) @@ -690,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, @@ -708,7 +646,7 @@ contract TokenBridgeCctpV1Test is Test { hook.messageTransmitter().nextAvailableNonce(), address(hook).addressToBytes32(), router, - router, + bytes32(0), abi.encode(Message.id(message)) ); @@ -750,7 +688,8 @@ contract TokenBridgeCctpV1Test is Test { TokenBridgeCctpV1 ism = TokenBridgeCctpV1(router.bytes32ToAddress()); _upgrade(ism); - vm.expectRevert(bytes("Invalid circle sender")); + // 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 @@ -871,13 +810,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); } @@ -934,6 +874,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 { @@ -1265,7 +1365,7 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { hook.hyperlaneDomainToCircleDomain(destination), address(hook).addressToBytes32(), ism, - ism, + bytes32(0), minFinalityThreshold, abi.encode(Message.id(message)) ); @@ -1388,7 +1488,7 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { ( cctpDestination, address(tbDestination).addressToBytes32(), - address(tbDestination).addressToBytes32(), + address(0).addressToBytes32(), minFinalityThreshold, abi.encode(id) ) @@ -1429,10 +1529,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); @@ -1457,4 +1557,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); + } }