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);
+    }
 }