Skip to content

Conversation

@yorhodes
Copy link
Member

@yorhodes yorhodes commented Oct 15, 2025

Description

how can we prevent a malicious relayer from manipulating a Hyperlane message by reusing an already-used CCTP nonce? Since CctpISM.verify() does not check the integrity of the CCTP message when the CCTP nonce has already been used, the Hyperlane message ID embedded in the CCTP message can no longer be trusted. In this case, the Hyperlane message itself can be tampered with. Thus, while a recipient may be designed to trust CctpISM and process the message, it coul ### Description

Fixes a critical vulnerability where malicious relayers could manipulate Hyperlane messages by reusing already-consumed CCTP nonces.

Vulnerability

Previously, CctpISM.verify() would skip CCTP verification if a nonce was already used but still return true. This allowed attackers to:

  1. Wait for a CCTP nonce to be consumed
  2. Call Mailbox.process() with an arbitrary message + the already-used CCTP attestation
  3. Have the ISM incorrectly verify the malicious message without checking its integrity

Solution

Always process CCTP messages: Remove nonce checks and always call messageTransmitter.receiveMessage() to validate attestations.
Authorized message ID pattern: Inherit from AbstractMessageIdAuthorizedIsm to prevent direct verify() calls that would waste nonces:

  • Circle's MessageTransmitter calls back into _receiveCircleMessage()
  • This registers the message ID via preVerifyMessage()
  • Only pre-verified messages can pass verify()

Changes

  • TokenBridgeCctpBase.sol: Add AbstractMessageIdAuthorizedIsm, bidirectional domain mapping, implement
    _receiveCircleMessage() callback
  • TokenBridgeCctpV1/V2.sol: Implement Circle's message handler callbacks
  • Tests: Updated mocks and added validation for authorization checks

Backward Compatibility

Breaking change - requires redeployment of CCTP ISM v1 and v2.

Summary by CodeRabbit

  • New Features

    • ISM-based authorization added for message verification.
    • Bidirectional domain mapping between Circle and Hyperlane exposed.
  • Improvements

    • Verification flow simplified with idempotency and short-circuit checks.
    • Relay and burn/deposit flows streamlined; message construction deferred to a single post-bridge step.
    • Mock relayers/handlers extended for V1/V2 message formats.
  • Bug Fixes

    • Removed fragile nonce/source gating to prevent false rejects.
  • Documentation

    • Added sequence diagrams illustrating cross-chain token flows.
  • Tests

    • Expanded V1/V2 coverage and adjusted expectations to new flows.

@changeset-bot
Copy link

changeset-bot bot commented Oct 15, 2025

⚠️ No Changeset found

Latest commit: 8250b2a

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@yorhodes yorhodes changed the base branch from main to audit-q3-2025 October 15, 2025 15:23
@yorhodes yorhodes marked this pull request as ready for review October 16, 2025 17:22
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 16, 2025

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

📝 Walkthrough

Walkthrough

This PR replaces nonce/source gating with message‑ID ISM attestations, adds bidirectional circle↔hyperlane domain maps, shifts bridge hooks to non‑returning deposits, makes mocks version‑aware for CCTP V1/V2 parsing/dispatch, and updates tests and docs accordingly.

Changes

Cohort / File(s) Summary
Base CCTP Bridge
solidity/contracts/token/TokenBridgeCctpBase.sol
Add AbstractMessageIdAuthorizedIsm via intermediate storage; introduce Domain struct, _hyperlaneDomainMap, and _circleDomainMap; add addDomain, circleDomainToHyperlaneDomain, update hyperlaneDomainToCircleDomain; change verify(...) to external override with pre‑verify short‑circuit and delegate reception to messageTransmitter.receiveMessage; add _receiveMessageId and _isAuthorized hooks; make _validateHookMessage internal pure virtual; _bridgeViaCircle no longer returns message.
V1 Protocol Handler
solidity/contracts/token/TokenBridgeCctpV1.sol
Remove nonce/source helpers and message‑length constant; add handleReceiveMessage(uint32, bytes32, bytes calldata) to decode body and call _receiveMessageId; add _messageId(bytes) helper; _bridgeViaCircle changed to non‑returning internal; _validateHookMessage now pure.
V2 Protocol Handler
solidity/contracts/token/TokenBridgeCctpV2.sol
Remove nonce/source helpers and length constant; change handleReceiveFinalizedMessage/handleReceiveUnfinalizedMessage to concrete overrides that decode/forward to _receiveMessageId; _sendMessageIdToIsm now passes bytes32(0) for ISM; _bridgeViaCircle made non‑returning; _validateHookMessage now pure.
Mock Circle Message Transmitter
solidity/contracts/mock/MockCircleMessageTransmitter.sol
Make receiveMessage stateful and version‑aware (V1/V2): parse formatted CCTP messages, enforce idempotency, extract recipient/sender/body, call IMessageHandler/IMessageHandlerV2; overload sendMessage to format full CCTP V1/V2 messages and emit MessageSent; add _bytes32ToAddress helper.
Mock Circle Token Messenger
solidity/contracts/mock/MockCircleTokenMessenger.sol
Implement IMessageHandler & IMessageHandlerV2; add handleReceiveMessage, handleReceiveFinalizedMessage, handleReceiveUnfinalizedMessage external pure overrides (return true).
Tests
solidity/test/token/TokenBridgeCctp.t.sol
Update tests for new public API (sendMessage), adapt validation expectations to handler‑driven verification, expand V1/V2 coverage, and adjust relay/send expectations to reflect sendMessage changes.
Docs
solidity/contracts/token/CCTP.md
Add destination‑chain sequence diagrams illustrating V1/V2 relayer and CCTP relayer flows (documentation only).

Sequence Diagram(s)

sequenceDiagram
    autonumber
    participant Relayer
    participant ISM as TokenBridgeCctpBase (ISM)
    participant MT as MessageTransmitter
    participant Handler as IMessageHandler / IMessageHandlerV2
    Note right of ISM: verify(metadata, hyperlaneMessage) entry

    Relayer->>ISM: verify(metadata, hyperlaneMessage)
    ISM->>ISM: if isVerified(_hyperlaneMessage) → return true
    ISM->>MT: messageTransmitter.receiveMessage(cctpMessage, metadata)
    activate MT
    MT->>MT: parse CCTP V1/V2, check idempotency, extract (source,sender,body,messageId)
    MT->>Handler: handleReceive*(source, sender, body)
    activate Handler
    Handler->>ISM: _receiveMessageId(circleSource, circleSender, messageId)
    ISM->>ISM: domain resolution & _isAuthorized() check
    Handler-->>MT: return true
    deactivate Handler
    MT-->>ISM: return true
    deactivate MT
    ISM-->>Relayer: return true
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested reviewers

  • tkporter
  • ltyu
  • nambrot

"Well, ain’t this tidy — domains now chat both ways,
Messages get checked by their IDs these days.
V1 and V2 sing in tune, mocks play along,
Hooks stopped returnin’ and tests hum a new song.
A wee refactor, neat as a swampy morn."

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Title Check ✅ Passed The PR title "fix: prevent CCTP v2 nonce manipulation" directly and clearly summarizes the primary change in this pull request. According to the PR objectives, the core fix is addressing a critical vulnerability where malicious relayers could manipulate Hyperlane messages by reusing already-consumed CCTP nonces. The title is concise, specific, and accurately reflects the main objective without unnecessary noise. A developer scanning the commit history would immediately understand that this PR tackles nonce manipulation prevention in the CCTP ISM implementation.
Description Check ✅ Passed The PR description provides comprehensive coverage of the critical sections needed to understand this change. It includes a detailed explanation of the vulnerability, clear articulation of the solution strategy, and a well-organized list of key changes across multiple files. The backward compatibility section explicitly states this is a breaking change requiring redeployment. Testing is mentioned with specifics about mock updates and validation additions. However, the description doesn't strictly follow the template structure—it uses custom section headings (Vulnerability, Solution, Changes) instead of the template's prescribed sections, and it lacks dedicated "Drive-by changes" and "Related issues" sections. That said, the substantive content is thorough and well-documented, with all critical information present.
Docstring Coverage ✅ Passed No functions found in the changes. Docstring coverage check skipped.
✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch cctp-nonce-manipulation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@yorhodes yorhodes requested a review from nambrot October 16, 2025 17:25
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
solidity/test/token/TokenBridgeCctp.t.sol (1)

93-95: Default hook set on origin twice — destination likely intended.

You set mailboxOrigin’s default hook to igpOrigin and then immediately overwrite it with igpDestination. That smells off; mailboxDestination should get igpDestination.

Apply:

-        mailboxOrigin.setDefaultHook(address(igpOrigin));
-        mailboxOrigin.setDefaultHook(address(igpDestination));
+        mailboxOrigin.setDefaultHook(address(igpOrigin));
+        mailboxDestination.setDefaultHook(address(igpDestination));
🧹 Nitpick comments (2)
solidity/contracts/mock/MockCircleMessageTransmitter.sol (1)

80-97: Optional: require handler success to mirror prod behavior.

Currently a non‑reverting false from the handler still “burns” the nonce. Consider requiring success to avoid accidental lockout.

Patch:

-        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
-                );
-        }
+        if (version == 0) {
+            success = IMessageHandler(recipient).handleReceiveMessage(
+                sourceDomain, sender, messageBody
+            );
+        } else {
+            success = IMessageHandlerV2(recipient).handleReceiveUnfinalizedMessage(
+                sourceDomain, sender, 1000, messageBody
+            );
+        }
+        require(success, "handler returned false");
solidity/contracts/token/TokenBridgeCctpV1.sol (1)

83-89: Light auth hint (optional).

You rely on AbstractMessageIdAuthorizedIsm inside preVerifyMessage for auth. That’s fine. If you want earlier failure, add a cheap guard here.

Example:

 function handleReceiveMessage(
     uint32 sourceDomain,
     bytes32 sender,
     bytes calldata body
 ) external override returns (bool) {
-    return _receiveCircleMessage(sourceDomain, sender, body);
+    // if (_isAuthorized()) { return _receiveCircleMessage(sourceDomain, sender, body); }
+    // revert("sender is not the hook");
+    return _receiveCircleMessage(sourceDomain, sender, body);
 }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 71a4e21 and a6ade29.

📒 Files selected for processing (5)
  • solidity/contracts/mock/MockCircleMessageTransmitter.sol (4 hunks)
  • solidity/contracts/token/TokenBridgeCctpBase.sol (11 hunks)
  • solidity/contracts/token/TokenBridgeCctpV1.sol (1 hunks)
  • solidity/contracts/token/TokenBridgeCctpV2.sol (3 hunks)
  • solidity/test/token/TokenBridgeCctp.t.sol (11 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
solidity/contracts/token/**/*.sol

📄 CodeRabbit inference engine (CLAUDE.md)

Place token bridge contracts (e.g., HypERC20, HypERC20Collateral) under solidity/contracts/token/

Files:

  • solidity/contracts/token/TokenBridgeCctpV1.sol
  • solidity/contracts/token/TokenBridgeCctpV2.sol
  • solidity/contracts/token/TokenBridgeCctpBase.sol
solidity/**/*.sol

📄 CodeRabbit inference engine (CLAUDE.md)

Lint Solidity code with solhint via yarn --cwd solidity lint

Files:

  • solidity/contracts/token/TokenBridgeCctpV1.sol
  • solidity/contracts/mock/MockCircleMessageTransmitter.sol
  • solidity/contracts/token/TokenBridgeCctpV2.sol
  • solidity/contracts/token/TokenBridgeCctpBase.sol
  • solidity/test/token/TokenBridgeCctp.t.sol
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (40)
  • GitHub Check: infra-test
  • GitHub Check: cli-e2e-matrix (warp-init)
  • GitHub Check: cli-e2e-matrix (warp-extend-recovery)
  • GitHub Check: cli-e2e-matrix (warp-send)
  • GitHub Check: cli-e2e-matrix (relay)
  • GitHub Check: cli-e2e-matrix (core-read)
  • GitHub Check: cli-e2e-matrix (warp-bridge-2)
  • GitHub Check: cli-e2e-matrix (warp-read)
  • GitHub Check: cli-e2e-matrix (warp-rebalancer)
  • GitHub Check: cli-e2e-matrix (warp-check)
  • GitHub Check: cli-e2e-matrix (warp-extend-config)
  • GitHub Check: cli-e2e-matrix (core-init)
  • GitHub Check: cli-e2e-matrix (warp-apply)
  • GitHub Check: cli-e2e-matrix (warp-bridge-1)
  • GitHub Check: cli-e2e-matrix (core-check)
  • GitHub Check: cli-e2e-matrix (warp-deploy)
  • GitHub Check: cli-e2e-matrix (warp-extend-basic)
  • GitHub Check: cli-e2e-matrix (core-apply)
  • GitHub Check: cli-e2e-matrix (core-deploy)
  • GitHub Check: env-test-matrix (mainnet3, arbitrum, core)
  • GitHub Check: env-test-matrix (testnet4, sepolia, core)
  • GitHub Check: env-test-matrix (mainnet3, optimism, core)
  • GitHub Check: env-test-matrix (mainnet3, optimism, igp)
  • GitHub Check: env-test-matrix (mainnet3, inevm, igp)
  • GitHub Check: env-test-matrix (mainnet3, inevm, core)
  • GitHub Check: env-test-matrix (mainnet3, ethereum, core)
  • GitHub Check: env-test-matrix (mainnet3, arbitrum, igp)
  • GitHub Check: env-test-matrix (mainnet3, ethereum, igp)
  • GitHub Check: coverage-run
  • GitHub Check: cosmos-sdk-e2e-run
  • GitHub Check: cli-install-test-run
  • GitHub Check: yarn-test-run
  • GitHub Check: lint-prettier
  • GitHub Check: diff-check
  • GitHub Check: lint-rs
  • GitHub Check: test-rs
  • GitHub Check: lander-coverage
  • GitHub Check: agent-configs (mainnet3)
  • GitHub Check: agent-configs (testnet4)
  • GitHub Check: slither
🔇 Additional comments (15)
solidity/test/token/TokenBridgeCctp.t.sol (4)

590-596: Switch to IRelayer.sendMessage looks right.

Expectation matches the new dispatch path and message-body encoding of id. No swamp gas here.


626-630: Good: ensure mapping before forging CCTP message.

Adding the domain mapping up front prevents brittle fork tests.


883-1010: Solid coverage for direct-delivery path.

You prove preVerify via handleReceiveMessage and then verify() short‑circuits on isVerified with empty metadata. Crisp.


1011-1041: Token message replay guard test is on point.

Simulating Circle consumption via receiveMessage and asserting verify() reverts hardens the nonce‑reuse edge.

solidity/contracts/token/TokenBridgeCctpV1.sol (2)

74-80: Hook message check is tight and minimal.

Comparing embedded circle messageId to hyperlane id is the right onion layer now that sender auth moved to the callback path.


103-114: Bridge call wiring looks correct for V1.

depositForBurn args match V1 ordering; no return plumbing needed anymore. Smooth.

solidity/contracts/token/TokenBridgeCctpV2.sol (3)

75-81: Fee inversion formula LGTM.

The reverse-fee calc matches the docstring reasoning. Rounding down is acceptable; tests cover it.


129-136: Correct delegation to base on both finalized and unfinalized paths.

Ignoring finalityThresholdExecuted at the interface edge is fine for the mock/test flow.

Also applies to: 139-146


153-160: Allow‑any relayer via destinationCaller=0 is intentional.

Matches the PR’s “anyone can relay” stance while still authorizing by messageId. All good.

solidity/contracts/token/TokenBridgeCctpBase.sol (6)

142-148: Intentional: token message encodes burnAmount (amount + externalFee).

This keeps destination validation aligned with Circle’s minted amount. Just noting for future readers: it’s not the user-facing “amount”.


177-185: Bidirectional domain maps: good.

Storing both directions avoids zero‑sentinel ambiguity and makes lookups O(1). Nice and tidy.


241-279: verify() flow is safer now.

  • Short‑circuit on isVerified avoids wasting nonces.
  • Recipient gating to {tokenMessenger | this} closes odd recipients.
  • Final receiveMessage call pushes attestation verification to Circle.
    No ogres lurking.

298-301: Auth hook is minimal and correct.

Only the messageTransmitter may call preVerify. Clean.


321-337: postDispatch path aligns with new ISM pattern.

You gate by dispatched id, send id over CCTP, and refund last. Looks sharp.


1-1: Manual solhint verification required—sandbox environment limitation.

The TokenBridgeCctpBase.sol file exists in the correct location and has a proper SPDX header. I ran the bonus check for lingering sendMessageWithCaller expectations and found none—those function references only show up in the legitimate interface and mock definitions, which is fine.

However, I can't actually execute yarn --cwd solidity lint in this sandbox (no node_modules environment to run the linter). The coding guidelines explicitly require solhint verification for Solidity files, so this needs to be validated in your CI/CD pipeline or locally before merge. Your typical yarn lint command should catch any issues—just make sure those solhint warnings stay happy.

Copy link
Contributor

@nambrot nambrot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would still love a quick ascii or mermaid diagram maybe of the flow as it is not really obvious, i wouldn't be surprised if we'll forget these flows and discussions in a mnth or so

cursor[bot]

This comment was marked as outdated.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (1)
solidity/contracts/token/TokenBridgeCctpBase.sol (1)

362-367: Rename _destination to circleDomain for precision.

This param is a Circle domain, not Hyperlane. Aligning the name avoids head‑scratching.

🧹 Nitpick comments (9)
solidity/contracts/token/TokenBridgeCctpV1.sol (2)

74-80: Add a tiny guard before slicing the message id.

index(0, 32) assumes at least 32 bytes. Add a quick length check to ogre‑proof this.

 function _validateHookMessage(
     bytes calldata hyperlaneMessage,
     bytes29 cctpMessage
 ) internal pure override {
-    bytes32 circleMessageId = cctpMessage._messageBody().index(0, 32);
+    bytes29 bodyView = cctpMessage._messageBody();
+    require(bodyView.len() >= 32, "Invalid hook body length");
+    bytes32 circleMessageId = bodyView.index(0, 32);
     require(circleMessageId == hyperlaneMessage.id(), "Invalid message id");
 }

84-94: Gate the handler by MessageTransmitter for clarity (preVerify already enforces it).

preVerifyMessage will revert if msg.sender isn’t the transmitter, but an explicit require here makes intent obvious and saves gas on bad calls.

 function handleReceiveMessage(
     uint32 sourceDomain,
     bytes32 sender,
     bytes calldata body
 ) external override returns (bool) {
+    require(msg.sender == address(messageTransmitter), "Unauthorized caller");
     return
         _receiveMessageId(
             sourceDomain,
             sender,
             abi.decode(body, (bytes32))
         );
 }
solidity/contracts/token/TokenBridgeCctpV2.sol (3)

120-126: Same guard before slicing the message id.

Let’s not poke at shorter bodies. Quick length check keeps the swamp calm.

 function _validateHookMessage(
     bytes calldata hyperlaneMessage,
     bytes29 cctpMessage
 ) internal pure override {
-    bytes32 circleMessageId = cctpMessage._getMessageBody().index(0, 32);
+    bytes29 bodyView = cctpMessage._getMessageBody();
+    require(bodyView.len() >= 32, "Invalid hook body length");
+    bytes32 circleMessageId = bodyView.index(0, 32);
     require(circleMessageId == hyperlaneMessage.id(), "Invalid message id");
 }

130-141: Gate both handlers by MessageTransmitter.

Like V1: add an explicit require to make the contract’s front door rules crystal clear.

 function handleReceiveFinalizedMessage(
     uint32 sourceDomain,
     bytes32 sender,
     uint32 /*finalityThresholdExecuted*/,
     bytes calldata messageBody
 ) external override returns (bool) {
+    require(msg.sender == address(messageTransmitter), "Unauthorized caller");
     return
         _receiveMessageId(
             sourceDomain,
             sender,
             abi.decode(messageBody, (bytes32))
         );
 }

 function handleReceiveUnfinalizedMessage(
     uint32 sourceDomain,
     bytes32 sender,
     uint32 /*finalityThresholdExecuted*/,
     bytes calldata messageBody
 ) external override returns (bool) {
+    require(msg.sender == address(messageTransmitter), "Unauthorized caller");
     return
         _receiveMessageId(
             sourceDomain,
             sender,
             abi.decode(messageBody, (bytes32))
         );
 }

Also applies to: 145-156


75-81: Round fees up to honor “minimum amount out.”

Integer division floors; recipients can end up 1–few wei short. Use ceilDiv to keep promises.

-    return (amount * maxFeeBps) / (10_000 - maxFeeBps);
+    uint256 denom = 10_000 - maxFeeBps;
+    unchecked {
+        // ceil((amount * maxFeeBps) / denom)
+        uint256 num = amount * maxFeeBps;
+        return (num + denom - 1) / denom;
+    }

Consider a quick test where amount % denom != 0.

solidity/contracts/token/TokenBridgeCctpBase.sol (4)

65-72: Enforce one‑to‑one domain mapping to avoid foot‑guns.

Currently, addDomain silently overwrites either side. Add collision checks so pairs stay bijective and mistakes don’t linger.

 function addDomain(
     uint32 _hyperlaneDomain,
     uint32 _circleDomain
 ) public onlyOwner {
+    Domain memory h = _hyperlaneDomainMap[_hyperlaneDomain];
+    require(h.hyperlane == 0 && h.circle == 0, "Hyperlane already mapped");
+    Domain memory c = _circleDomainMap[_circleDomain];
+    require(c.hyperlane == 0 && c.circle == 0, "Circle already mapped");
     _hyperlaneDomainMap[_hyperlaneDomain] = Domain(
         _hyperlaneDomain,
         _circleDomain
     );
     _circleDomainMap[_circleDomain] = Domain(
         _hyperlaneDomain,
         _circleDomain
     );
     emit DomainAdded(_hyperlaneDomain, _circleDomain);
 }

If domain 0 is ever valid, store an extra “isSet” flag instead of sentinel 0s.

Also applies to: 178-186


284-299: Add explicit transmitter gate in _receiveMessageId.

preVerify already enforces this via _isAuthorized(), but a direct require gives earlier, cheaper failures and documents intent.

 function _receiveMessageId(
     uint32 circleSource,
     bytes32 circleSender,
     bytes32 messageId
 ) internal returns (bool) {
+    require(msg.sender == address(messageTransmitter), "Unauthorized caller");
     // 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;
 }

4-5: Drop the duplicate TokenRouter import.

One import is plenty — keeps lint lintless.

-import {TokenRouter} from "./libs/TokenRouter.sol";
 ...
-import {TokenRouter} from "./libs/TokenRouter.sol";

Also applies to: 18-19


325-341: Minor naming nit: “ism” here is the remote router.

Consider renaming the local variable to remoteRouter for clarity. No behavior change, just fewer onion layers to peel when reading.

- bytes32 ism = _mustHaveRemoteRouter(destination);
+ bytes32 remoteRouter = _mustHaveRemoteRouter(destination);
- _sendMessageIdToIsm(circleDestination, ism, id);
+ _sendMessageIdToIsm(circleDestination, remoteRouter, id);
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a6ade29 and 93ff524.

📒 Files selected for processing (4)
  • solidity/contracts/token/CCTP.md (1 hunks)
  • solidity/contracts/token/TokenBridgeCctpBase.sol (11 hunks)
  • solidity/contracts/token/TokenBridgeCctpV1.sol (1 hunks)
  • solidity/contracts/token/TokenBridgeCctpV2.sol (3 hunks)
✅ Files skipped from review due to trivial changes (1)
  • solidity/contracts/token/CCTP.md
🧰 Additional context used
📓 Path-based instructions (2)
solidity/contracts/token/**/*.sol

📄 CodeRabbit inference engine (CLAUDE.md)

Place token bridge contracts (e.g., HypERC20, HypERC20Collateral) under solidity/contracts/token/

Files:

  • solidity/contracts/token/TokenBridgeCctpV1.sol
  • solidity/contracts/token/TokenBridgeCctpBase.sol
  • solidity/contracts/token/TokenBridgeCctpV2.sol
solidity/**/*.sol

📄 CodeRabbit inference engine (CLAUDE.md)

Lint Solidity code with solhint via yarn --cwd solidity lint

Files:

  • solidity/contracts/token/TokenBridgeCctpV1.sol
  • solidity/contracts/token/TokenBridgeCctpBase.sol
  • solidity/contracts/token/TokenBridgeCctpV2.sol
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (41)
  • GitHub Check: env-test-matrix (mainnet3, ethereum, core)
  • GitHub Check: cli-e2e-matrix (relay)
  • GitHub Check: env-test-matrix (mainnet3, optimism, igp)
  • GitHub Check: env-test-matrix (mainnet3, ethereum, igp)
  • GitHub Check: cli-e2e-matrix (warp-rebalancer)
  • GitHub Check: cli-e2e-matrix (warp-extend-config)
  • GitHub Check: env-test-matrix (mainnet3, inevm, igp)
  • GitHub Check: env-test-matrix (mainnet3, inevm, core)
  • GitHub Check: env-test-matrix (testnet4, sepolia, core)
  • GitHub Check: env-test-matrix (mainnet3, arbitrum, igp)
  • GitHub Check: env-test-matrix (mainnet3, arbitrum, core)
  • GitHub Check: cli-e2e-matrix (warp-send)
  • GitHub Check: env-test-matrix (mainnet3, optimism, core)
  • GitHub Check: cli-e2e-matrix (core-deploy)
  • GitHub Check: cli-e2e-matrix (warp-extend-basic)
  • GitHub Check: cli-e2e-matrix (warp-extend-recovery)
  • GitHub Check: cli-e2e-matrix (warp-read)
  • GitHub Check: cli-e2e-matrix (core-init)
  • GitHub Check: cli-e2e-matrix (core-read)
  • GitHub Check: cli-e2e-matrix (warp-init)
  • GitHub Check: cli-e2e-matrix (warp-check)
  • GitHub Check: cli-e2e-matrix (warp-bridge-2)
  • GitHub Check: cli-e2e-matrix (warp-deploy)
  • GitHub Check: cli-e2e-matrix (warp-apply)
  • GitHub Check: cli-e2e-matrix (warp-bridge-1)
  • GitHub Check: cli-e2e-matrix (core-check)
  • GitHub Check: cli-e2e-matrix (core-apply)
  • GitHub Check: yarn-test-run
  • GitHub Check: cli-install-test-run
  • GitHub Check: cosmos-sdk-e2e-run
  • GitHub Check: coverage-run
  • GitHub Check: Cursor Bugbot
  • GitHub Check: lint-rs
  • GitHub Check: test-rs
  • GitHub Check: lander-coverage
  • GitHub Check: slither
  • GitHub Check: lint-prettier
  • GitHub Check: yarn-install
  • GitHub Check: agent-configs (mainnet3)
  • GitHub Check: agent-configs (testnet4)
  • GitHub Check: diff-check
🔇 Additional comments (7)
solidity/contracts/token/TokenBridgeCctpV1.sol (2)

101-106: Confirm the V1 send API choice.

Swapping sendMessageWithCaller -> sendMessage removes the destinationCaller constraint. That matches CCTP V1’s interface, but just sanity‑check we don’t rely on caller restriction on the destination in V1. If we do, we’ll need an alternate guard downstream.


112-119: V1 depositForBurn arguments look correct.

(amount, circleDomain, mintRecipient, burnToken) ordering matches V1. No nits here.

solidity/contracts/token/TokenBridgeCctpV2.sol (2)

158-170: “Allow anyone to relay” — double‑check the threat model.

bytes32(0) for caller is intentional per comment. Confirm downstream relies solely on AbstractMessageIdAuthorizedIsm + circle sender/domain checks so open relays can’t grief or escalate. If any environment expects caller pinning, surface a config switch.


172-186: V2 depositForBurn args and flags look consistent.

Relay open, fee and finality threaded through. Looks good.

solidity/contracts/token/TokenBridgeCctpBase.sol (3)

20-20: Nice integration of AbstractMessageIdAuthorizedIsm.

This closes the nonce‑waste hole and ties preVerify to the transmitter. Clean.

Also applies to: 43-45


145-149: Confirm TokenMessage.amount = burnAmount is intended for V2 as well.

Base encodes burnAmount (amount + externalFee). In V2, Circle mints (burnAmount − fee). Your _validateTokenMessage compares TokenMessage.amount to the Circle burn message’s amount; depending on CCTP V2 semantics, that may be burn or mint amount. If Circle’s burn message reports the pre‑fee amount, we’re good. If it reports post‑fee (mint) amount, V2 should encode amount instead or override transferRemote.

Happy to draft a V2-specific override if needed.


242-283: Always calling receiveMessage fixes the reused‑nonce bypass.

Short‑circuit on isVerified, then defer to Circle for attestation/nonce checks — this is the right shape. Ogre‑thumbs up.

@yorhodes yorhodes merged commit e7e9cd8 into audit-q3-2025 Oct 20, 2025
7 of 12 checks passed
@yorhodes yorhodes deleted the cctp-nonce-manipulation branch October 20, 2025 15:39
@github-project-automation github-project-automation bot moved this from In Review to Done in Hyperlane Tasks Oct 20, 2025
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (4)
solidity/test/token/TokenBridgeCctp.t.sol (4)

426-426: Use assertTrue instead of assert for consistency.

The rest of the test suite uses Foundry's assertion helpers like assertTrue. Stick with that pattern for better error messages and consistency.

-        assert(recipient.verify(metadata, message));
+        assertTrue(recipient.verify(metadata, message));

1006-1036: Consider asserting a specific revert message.

The test expects a generic revert when the CCTP nonce is already consumed, but it would be more robust to verify the specific error from Circle's MessageTransmitter (e.g., "Nonce already used" or similar).

If the mock doesn't provide a specific message, consider adding one or at least documenting what the expected Circle behavior is.


1532-1535: Remove commented code or use vm.skip(true) pattern.

Since V2 doesn't need this test (nonces aren't validated the same way), either delete the commented lines entirely or use the vm.skip(true) pattern you're already using for other overridden tests (like testFork_verify_upgrade at line 1537-1539).

-    // 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);
+        // V2 uses hash-based nonces; cannot assert specific nonce values
+    }

1853-1883: Token message already-delivered test looks good.

Like the V1 version, this expects a generic revert when the CCTP nonce is consumed. Same suggestion as before: if possible, assert the specific Circle error message for robustness.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 93ff524 and 8250b2a.

📒 Files selected for processing (2)
  • solidity/contracts/token/TokenBridgeCctpBase.sol (11 hunks)
  • solidity/test/token/TokenBridgeCctp.t.sol (11 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
solidity/**/*.sol

📄 CodeRabbit inference engine (CLAUDE.md)

Lint Solidity code with solhint via yarn --cwd solidity lint

Files:

  • solidity/test/token/TokenBridgeCctp.t.sol
  • solidity/contracts/token/TokenBridgeCctpBase.sol
solidity/contracts/token/**/*.sol

📄 CodeRabbit inference engine (CLAUDE.md)

Place token bridge contracts (e.g., HypERC20, HypERC20Collateral) under solidity/contracts/token/

Files:

  • solidity/contracts/token/TokenBridgeCctpBase.sol
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (6)
  • GitHub Check: Cursor Bugbot
  • GitHub Check: slither
  • GitHub Check: test-rs
  • GitHub Check: lint-rs
  • GitHub Check: lander-coverage
  • GitHub Check: diff-check
🔇 Additional comments (17)
solidity/test/token/TokenBridgeCctp.t.sol (10)

590-606: LGTM! API change correctly reflected.

The switch from sendMessageWithCaller to sendMessage matches the updated CCTP interface. Test expectations are spot on.


626-629: Good addition - ensures domain mapping exists.

Adding the domain mapping setup here makes the test more robust and aligns with the new bidirectional mapping architecture.


649-649: LGTM! Using bytes32(0) to allow any relayer.

Setting destinationCaller to bytes32(0) lets any relayer deliver the message, which is correct for GMP messages where the ISM validates the sender.


691-693: Good documentation of the validation flow change.

These comments accurately reflect that sender validation now happens inside the receiveMessage callback via _authenticateCircleSender, which is central to the nonce manipulation fix.

Also applies to: 819-822


813-813: Correct message ID embedding for hook messages.

Encoding the message ID here aligns with the authorized message ID pattern where GMP messages carry their ID in the CCTP body.


878-1004: Excellent test coverage for V1 handleReceiveMessage.

These tests comprehensively cover the callback flow: success path, authorization checks (sender and caller), domain configuration, router enrollment, and idempotency. Well structured.


1368-1368: LGTM! V2 correctly uses address(0) for any-relayer delivery.

Like the V1 tests, using address(0).addressToBytes32() for destinationCaller allows any relayer to deliver GMP messages, which is the intended behavior for V2.

Also applies to: 1491-1491


1560-1677: Strong V2 test coverage for handleReceiveFinalizedMessage.

The V2 callback tests mirror the V1 structure but correctly use handleReceiveFinalizedMessage with the finalityThreshold parameter. All error paths (unauthorized sender/caller, unconfigured domain, unenrolled router) are covered.


1679-1769: Comprehensive coverage for handleReceiveUnfinalizedMessage.

These tests parallel the finalized message tests but exercise the unfinalized path. Good symmetry and coverage of both V2 callback variants.


1771-1851: Good idempotency and post-delivery verification tests.

These tests verify that messages can't be delivered twice and that verify() works correctly after direct delivery via callbacks. The coverage of both finalized (lines 1797-1823) and unfinalized (lines 1825-1851) paths is thorough.

solidity/contracts/token/TokenBridgeCctpBase.sol (7)

20-20: Well-structured storage layout and inheritance chain.

The intermediate storage contracts and bidirectional domain mappings are thoughtfully designed. Using a struct with both domains avoids ambiguity when domain 0 is involved, and the careful storage slot management prevents upgrade issues.

Also applies to: 39-60, 75-78


152-153: Cleaner _bridgeViaCircle signature.

Removing the return value and passing burnAmount directly makes the control flow more explicit. Good refactor.

Also applies to: 373-373


185-192: Bidirectional mapping correctly implemented.

Populating both _hyperlaneDomainMap and _circleDomainMap with the full Domain struct enables efficient lookups in both directions, which is essential for the callback-driven validation flow.


206-225: Domain conversion functions look solid.

Using the struct comparison (domain.hyperlane == _hyperlaneDomain) to verify the domain is configured prevents silent failures when domain 0 is involved. Both conversion directions are properly implemented.


252-289: Core vulnerability fix: always process CCTP messages, check isVerified first.

This is the heart of the nonce manipulation fix:

  • Line 261: Early return if message already verified prevents bypass
  • Line 288: Always calls receiveMessage() to validate attestations (no more nonce skipping)
  • Token messages may revert if nonce consumed; GMP messages succeed because ISM is the only valid caller

The flow is now secure and correctly documented.


291-310: Proper authorization and message ID registration.

The callback functions are well-protected:

  • _receiveMessageId (lines 291-306): Validates the circle sender against enrolled routers, then registers the message ID via preVerifyMessage
  • _isAuthorized (lines 308-310): Ensures only Circle's MessageTransmitter can trigger callbacks

This prevents attackers from directly calling verify() or pre-registering arbitrary message IDs.


332-333: Good documentation of the routing pattern.

The comment clearly explains that this mirrors AbstractMessageIdAuthHook._postDispatch but leverages the Router table instead of direct hook-ISM coupling. Helpful for understanding the architecture.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants