Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions contracts/src/MessageTransmitterMock.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

/// @title MessageTransmitterMock — Circle CCTP MessageTransmitter mock for local testing.
/// @notice Deployed on Anvil by Puente. Handles sendMessage (burn side) and
/// receiveMessage (mint side) for CCTP cross-chain USDC transfers.
contract MessageTransmitterMock {

// ───── Events ─────

/// @notice Emitted when a message is sent (depositForBurn calls this internally).
event MessageSent(bytes message);

/// @notice Emitted when a message is received and processed.
event MessageReceived(
uint32 sourceDomain,
uint64 nonce,
bytes32 sender,
bytes messageBody
);

// ───── State ─────

uint32 public localDomain;
uint64 public nextAvailableNonce;

/// @notice The attester public address (derived from the test private key).
address public attester;

/// @notice Tracks which nonces have been used (prevents replay).
mapping(bytes32 => bool) public usedNonces;

// ───── Constructor ─────

/// @param _localDomain The CCTP domain ID for this chain (0=Ethereum, 5=Solana).
/// @param _attester The expected attester address for signature verification.
constructor(uint32 _localDomain, address _attester) {
localDomain = _localDomain;
attester = _attester;
nextAvailableNonce = 1;
}

// ───── Admin ─────

function setAttester(address _attester) external {
attester = _attester;
}

// ───── Source Side: sendMessage ─────

/// @notice Send a cross-chain message. Called by TokenMessenger.depositForBurn().
/// @param destinationDomain The CCTP domain of the destination chain.
/// @param recipient The recipient on the destination chain (bytes32).
/// @param messageBody The message body (BurnMessage for USDC transfers).
/// @return nonce The nonce assigned to this message.
function sendMessage(
uint32 destinationDomain,
bytes32 recipient,
bytes calldata messageBody
) external returns (uint64) {
uint64 nonce = nextAvailableNonce++;

// Encode the full CCTP message
bytes memory message = abi.encodePacked(
uint32(0), // version
localDomain, // source_domain
destinationDomain, // destination_domain
nonce, // nonce
bytes32(uint256(uint160(msg.sender))), // sender (left-padded address)
recipient, // recipient
bytes32(0), // destination_caller (zero = any)
messageBody // body
);

emit MessageSent(message);

return nonce;
}

// ───── Destination Side: receiveMessage ─────

/// @notice Receive and process a cross-chain message.
/// @dev In production, this verifies the attestation signature against the attester set.
/// In the mock, we do simplified ECDSA recovery verification.
/// @param message The raw CCTP message bytes.
/// @param attestation The 65-byte ECDSA attestation signature (r+s+v).
/// @return success Whether the message was processed.
function receiveMessage(
bytes calldata message,
bytes calldata attestation
) external returns (bool) {
require(message.length >= 116, "message too short");

// Parse header fields
uint32 version;
uint32 sourceDomain;
uint32 destinationDomain;
uint64 nonce;

assembly {
// message is a calldata bytes, data starts at message.offset
let ptr := message.offset
version := shr(224, calldataload(ptr))
sourceDomain := shr(224, calldataload(add(ptr, 4)))
destinationDomain := shr(224, calldataload(add(ptr, 8)))
nonce := shr(192, calldataload(add(ptr, 12)))
}

require(version == 0, "invalid version");
require(destinationDomain == localDomain, "wrong destination domain");

// Replay protection: each (sourceDomain, nonce) pair can only be used once
bytes32 nonceKey = keccak256(abi.encodePacked(sourceDomain, nonce));
require(!usedNonces[nonceKey], "nonce already used");
usedNonces[nonceKey] = true;

// Verify attestation (simplified: recover signer from ECDSA signature)
if (attestation.length >= 65) {
bytes32 digest = keccak256(message);
bytes32 r;
bytes32 s;
uint8 v;
assembly {
let ptr := attestation.offset
r := calldataload(ptr)
s := calldataload(add(ptr, 32))
v := byte(0, calldataload(add(ptr, 64)))
}
// Normalize v (some signers use 0/1, others use 27/28)
if (v < 27) v += 27;
address recovered = ecrecover(digest, v, r, s);
require(recovered == attester, "invalid attestation");
}

// Extract sender and messageBody for the event
bytes32 sender;
assembly {
sender := calldataload(add(message.offset, 20))
}

bytes memory messageBody;
if (message.length > 116) {
messageBody = message[116:];
}

emit MessageReceived(sourceDomain, nonce, sender, messageBody);

return true;
}

// ───── View helpers ─────

function isNonceUsed(uint32 sourceDomain, uint64 nonce) external view returns (bool) {
bytes32 nonceKey = keccak256(abi.encodePacked(sourceDomain, nonce));
return usedNonces[nonceKey];
}
}
3 changes: 3 additions & 0 deletions crates/puente/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,6 @@ openssl = { workspace = true }
base64 = { workspace = true }
tiny-keccak = { workspace = true }
ureq = { workspace = true }
# CCTP integration: secp256k1 signing for mock attestations
k256 = { version = "0.13", features = ["ecdsa"] }
hex = "0.4"
Loading
Loading