// SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.24; import {ICrossChainVerifierV1} from "../interfaces/ICrossChainVerifierV1.sol"; import {IMessageTransmitter} from "../pools/USDC/interfaces/IMessageTransmitter.sol"; import {ITokenMessenger} from "../pools/USDC/interfaces/ITokenMessenger.sol"; import {FeeTokenHandler} from "../libraries/FeeTokenHandler.sol"; import {FinalityCodec} from "../libraries/FinalityCodec.sol"; import {Internal} from "../libraries/Internal.sol"; import {MessageV1Codec} from "../libraries/MessageV1Codec.sol"; import {CCTPMessageTransmitterProxy} from "../pools/USDC/CCTPMessageTransmitterProxy.sol"; import {BaseVerifier} from "./components/BaseVerifier.sol"; import {Ownable2StepMsgSender} from "@chainlink/contracts/src/v0.8/shared/access/Ownable2StepMsgSender.sol"; import {IERC20} from "@openzeppelin/contracts@5.3.0/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts@5.3.0/token/ERC20/utils/SafeERC20.sol"; /// @notice The CCTPVerifier creates USDC burn messages on source and delivers them on destination. /// @dev This verifier is for CCTP V2 and is not backwards compatible with CCTP V1. contract CCTPVerifier is Ownable2StepMsgSender, BaseVerifier { using SafeERC20 for IERC20; error InvalidVerifierResults(); error InvalidCCVVersion(bytes4 expected, bytes4 got); error InvalidMessageTransmitterOnProxy(address expected, address got); error InvalidMessageTransmitterVersion(uint32 expected, uint32 got); error InvalidReceiver(bytes receiver); error InvalidTokenMessengerVersion(uint32 expected, uint32 got); error InvalidMessageId(bytes32 expected, bytes32 got); error InvalidMessageSender(bytes32 expected, bytes32 got); error InvalidSourceDomain(uint32 expected, uint32 got); error InvalidToken(bytes token); error InvalidTokenTransferLength(uint256 length); error InvalidVerifierArgsLength(uint256 length); error OnlyCallableByOwnerOrAllowlistAdmin(); error ReceiveMessageCallFailed(); error InvalidFastFinalityBps(uint16 fastFinalityBps); error InvalidSetDomainArgs(SetDomainArgs args); error UnknownDomain(uint64 chainSelector); event DomainsSet(SetDomainArgs[] domains); event DynamicConfigSet(DynamicConfig dynamicConfig); event StaticConfigSet( address tokenMessenger, address messageTransmitterProxy, address usdcToken, uint32 localDomainIdentifier ); /// @notice The arguments required to update a remote domain. struct SetDomainArgs { bytes32 allowedCallerOnDest; // Address allowed to call receiveMessage on the domain (i.e. the MessageTransmitterProxy). bytes32 allowedCallerOnSource; // Address allowed to call depositForBurn on the domain. bytes32 mintRecipientOnDest; // Address to mint USDC to on the destination chain. uint64 chainSelector; // The corresponding CCIP destination chain selector for the domain. uint32 domainIdentifier; // Unique domain ID used across CCTP. bool enabled; // Whether or not the domain is enabled. } /// @notice Parameters for _depositForBurn (stack too deep measure). struct DepositForBurnParams { bytes32 messageId; // The message ID of the CCIP message. uint32 finalityThreshold; // The CCTP finality threshold. } /// @notice A domain is a CCTP-specific representation of a destination chain. /// @dev Zero is a valid domain identifier. struct Domain { bytes32 allowedCallerOnDest; // Address allowed to call receiveMessage on the domain (i.e. the MessageTransmitterProxy). bytes32 allowedCallerOnSource; // Address allowed to call depositForBurn on the domain. bytes32 mintRecipientOnDest; // Address to mint USDC to on the destination chain. uint32 domainIdentifier; // ─╮ Unique domain ID used across CCTP. bool enabled; // ────────────╯ Whether or not the domain is enabled. } /// @notice Dynamic configuration for this chain. struct DynamicConfig { address feeAggregator; // Address to which fees are withdrawn. address allowlistAdmin; // ─╮ Address permitted to update the allowlist, in addition to the owner. uint16 fastFinalityBps; // ─╯ Basis points charged for fast finality on destination. } /// @notice The arguments required for the BaseVerifier constructor. Needed to avoid "Stack too deep" errors. struct BaseVerifierArgs { string[] storageLocations; address rmn; bytes4 versionTag; } string public constant override typeAndVersion = "CCTPVerifier 2.0.0"; /// @notice CCTP contracts use the number 1 to represent V2, as 0 represents V1. uint32 private constant SUPPORTED_CCTP_VERSION = 1; /// @notice The division factor for basis points. This also represents the maximum bps fee. uint16 private constant BPS_DIVIDER = 10_000; /// @notice The length of a CCTP message, including the message body + hook data expected by this verifier. /// @dev Message format. /// * Field Bytes Type Index /// * version 4 uint32 0 /// * sourceDomain 4 uint32 4 /// * destinationDomain 4 uint32 8 /// * nonce 32 bytes32 12 /// * sender 32 bytes32 44 /// * recipient 32 bytes32 76 /// * destinationCaller 32 bytes32 108 /// * minFinalityThreshold 4 uint32 140 /// * finalityThresholdExecuted 4 uint32 144 /// * messageBody dynamic bytes 148 /// @dev CCTP burn message body format. /// * Field Bytes Type Index /// * version 4 uint32 0 /// * burnToken 32 bytes32 4 /// * mintRecipient 32 bytes32 36 /// * amount 32 uint256 68 /// * messageSender 32 bytes32 100 /// * maxFee 32 uint256 132 /// * feeExecuted 32 uint256 164 /// * expirationBlock 32 uint256 196 /// * hookData dynamic bytes 228 /// @dev Hook data format. /// * Field Bytes Type Index /// * verifierVersion 4 bytes4 0 /// * messageId 32 bytes32 4 /// @dev Total CCTP message bytes = (4 * 3) + (32 * 4) + (4 * 2) + 4 + (32 * 7) + 4 + 32 = 412. uint256 private constant CCTP_MESSAGE_SIZE = 412; /// @notice The number of bytes in the verifier version. /// @dev We include the verifier version in the prefix to enable compatibility with version-based proxy contracts. /// We also include it within the hook data to ensure that it gets signed by the attestation service. uint256 private constant VERIFIER_VERSION_SIZE = 4; /// @notice Total Verifier Results bytes = VERIFIER_VERSION_SIZE + CCTP_MESSAGE_SIZE + 65 (ECDSA signature with recovery byte). /// CCTP message transmitter requires a minimum signature threshold of 1, so we account for at least one signature here. uint256 private constant MINIMUM_VERIFIER_RESULT_SIZE = VERIFIER_VERSION_SIZE + CCTP_MESSAGE_SIZE + 65; /// @notice The starting index of the sourceDomain in the Verifier Results (CCTP message offset 4). uint256 private constant SOURCE_DOMAIN_START = VERIFIER_VERSION_SIZE + 4; /// @notice The starting index of the messageSender in the Verifier Results. uint256 private constant MESSAGE_SENDER_START = VERIFIER_VERSION_SIZE + 148 + 100; /// @notice The starting index of the verifier version (hook data location) in the Verifier Results. uint256 private constant VERIFIER_VERSION_START = VERIFIER_VERSION_SIZE + 148 + 228; /// @notice The starting index of the message ID in the Verifier Results. uint256 private constant MESSAGE_ID_START = VERIFIER_VERSION_SIZE + 148 + 228 + VERIFIER_VERSION_SIZE; /// @notice The standard finality threshold for CCTP. /// @dev Used when CCIP finality == 0 (i.e. the default value). uint32 private constant CCTP_STANDARD_FINALITY_THRESHOLD = 2000; /// @notice The fast finality threshold for CCTP. /// @dev Used when CCIP finality > 0. uint32 private constant CCTP_FAST_FINALITY_THRESHOLD = 1000; /// @notice The USDC token contract. IERC20 private immutable i_usdcToken; /// @notice The message transmitter proxy, which is used on destination as a non-upgradeable caller of all CCTP messages. /// @dev Instead of calling receiveMessage directly, we use a proxy to enable upgrades to the verifier without invalidating in-flight messages. /// CCTP messages define an address permitted to call receiveMessage, which will always be the message transmitter proxy. CCTPMessageTransmitterProxy private immutable i_messageTransmitterProxy; /// @notice The token messenger, which is used on source to send USDC over CCTP. /// @dev The token messenger calls into the message transmitter after burning USDC and forming the app-specific message body. ITokenMessenger private immutable i_tokenMessenger; /// @notice The local domain identifier, i.e. a CCTP-specific identifier for the chain to which this contract is deployed. uint32 private immutable i_localDomainIdentifier; /// @notice A mapping of CCIP chain selectors to CCTP domain configurations. mapping(uint64 remoteChainSelector => Domain cctpDomain) private s_chainToDomain; /// @notice The dynamic configuration. DynamicConfig private s_dynamicConfig; constructor( ITokenMessenger tokenMessenger, CCTPMessageTransmitterProxy messageTransmitterProxy, IERC20 usdcToken, DynamicConfig memory dynamicConfig, BaseVerifierArgs memory baseVerifierArgs ) BaseVerifier(baseVerifierArgs.storageLocations, baseVerifierArgs.rmn, baseVerifierArgs.versionTag) { if ( address(tokenMessenger) == address(0) || address(messageTransmitterProxy) == address(0) || address(usdcToken) == address(0) ) revert ZeroAddressNotAllowed(); // Ensure that the token messenger is for CCTP. uint32 tokenMessengerVersion = tokenMessenger.messageBodyVersion(); if (tokenMessengerVersion != SUPPORTED_CCTP_VERSION) { revert InvalidTokenMessengerVersion(SUPPORTED_CCTP_VERSION, tokenMessengerVersion); } // Ensure that the message transmitter is for CCTP. IMessageTransmitter messageTransmitter = IMessageTransmitter(tokenMessenger.localMessageTransmitter()); uint32 messageTransmitterVersion = messageTransmitter.version(); if (messageTransmitterVersion != SUPPORTED_CCTP_VERSION) { revert InvalidMessageTransmitterVersion(SUPPORTED_CCTP_VERSION, messageTransmitterVersion); } // Ensure that the message transmitter on the proxy is the same as the message transmitter on the token messenger. address messageTransmitterOnProxy = address(messageTransmitterProxy.i_cctpTransmitter()); if (messageTransmitterOnProxy != address(messageTransmitter)) { revert InvalidMessageTransmitterOnProxy(address(messageTransmitter), messageTransmitterOnProxy); } // Set the immutable state variables. i_tokenMessenger = tokenMessenger; i_messageTransmitterProxy = messageTransmitterProxy; i_localDomainIdentifier = messageTransmitter.localDomain(); i_usdcToken = usdcToken; // Approve the token messenger to burn the USDC token on behalf of this contract. // The USDC token pool will be responsible for forwarding USDC it receives from the router to this contract. i_usdcToken.forceApprove(address(i_tokenMessenger), type(uint256).max); emit StaticConfigSet( address(i_tokenMessenger), address(i_messageTransmitterProxy), address(i_usdcToken), i_localDomainIdentifier ); _setDynamicConfig(dynamicConfig); } /// @inheritdoc ICrossChainVerifierV1 function forwardToVerifier( MessageV1Codec.MessageV1 calldata message, bytes32 messageId, address, // feeToken uint256, // feeTokenAmount bytes calldata verifierArgs ) external returns (bytes memory verifierReturnData) { _assertNotCursedByRMN(message.destChainSelector); // For EVM, sender is expected to be abi encoded. _assertSenderIsAllowed(message.destChainSelector, abi.decode(message.sender, (address))); Domain storage domain = s_chainToDomain[message.destChainSelector]; if (!domain.enabled) revert UnknownDomain(message.destChainSelector); // We expect exactly one token transfer per message. if (message.tokenTransfer.length != 1) revert InvalidTokenTransferLength(message.tokenTransfer.length); MessageV1Codec.TokenTransferV1 memory tokenTransfer = message.tokenTransfer[0]; // The address of the token transferred must correspond to USDC. if (abi.decode(tokenTransfer.sourceTokenAddress, (address)) != address(i_usdcToken)) { revert InvalidToken(tokenTransfer.sourceTokenAddress); } if (tokenTransfer.tokenReceiver.length > 32) { revert InvalidReceiver(tokenTransfer.tokenReceiver); } bytes32 decodedReceiver; // For EVM chains, the mintRecipient is not used. // Solana requires it, as the mintRecipient will be a PDA owned by the pool. // The PDA will forward the tokens to their final destination after minting. if (domain.mintRecipientOnDest != bytes32(0)) { decodedReceiver = domain.mintRecipientOnDest; } else { decodedReceiver = Internal._leftPadBytesToBytes32(tokenTransfer.tokenReceiver); } DepositForBurnParams memory params = DepositForBurnParams({messageId: messageId, finalityThreshold: CCTP_STANDARD_FINALITY_THRESHOLD}); // The maximum fee, taken on destination, is a portion of the total amount transferred. uint256 maxFee = 0; if (message.finality != FinalityCodec.WAIT_FOR_FINALITY_FLAG) { params.finalityThreshold = CCTP_FAST_FINALITY_THRESHOLD; if (verifierArgs.length > 0) { // We interpret verifierArgs as the max fee. // CCTP defines bps offchain, so computing a max fee based on the API and inputting it into ccipSend // is the best way to ensure that your max fee aligns with what CCTP will charge for your transfer. if (verifierArgs.length != 32) revert InvalidVerifierArgsLength(verifierArgs.length); maxFee = abi.decode(verifierArgs, (uint256)); } else { // If no verifierArgs are provided, we compute the max fee according to the bps stored on this contract. // The bps values stored on this contract should be kept in-sync with those used by CCTP. // If out of sync, the following scenarios are possible: // - If stored bps < actual bps, the user just gets a standard, free transfer. // - If stored bps > actual bps, the user pays less on destination than they were expecting to. // Neither scenario results in a user paying more than they were expecting to. maxFee = tokenTransfer.amount * s_dynamicConfig.fastFinalityBps / BPS_DIVIDER; } } i_tokenMessenger.depositForBurnWithHook( tokenTransfer.amount, domain.domainIdentifier, decodedReceiver, address(i_usdcToken), domain.allowedCallerOnDest, maxFee, params.finalityThreshold, // The hook data includes the version tag and the message ID. // The version tag allows the destination verifier entity to route the message to the correct implementation. // Inclusion of the message ID ensures that the contents of the CCIP message can't be tampered with on destination. bytes.concat(versionTag(), params.messageId) ); // We do not return the verifier version here. // Offchain verifier is expected to pull verifier version from the hook data & prefix the verifierResults with it. return ""; } /// @inheritdoc ICrossChainVerifierV1 function verifyMessage( MessageV1Codec.MessageV1 memory message, bytes32 messageHash, bytes calldata verifierResults ) external { _assertNotCursedByRMN(message.sourceChainSelector); _onlyOffRamp(message.sourceChainSelector); if (verifierResults.length < MINIMUM_VERIFIER_RESULT_SIZE) revert InvalidVerifierResults(); bytes4 versionPrefix = bytes4(verifierResults[:VERIFIER_VERSION_SIZE]); if (versionPrefix != versionTag()) revert InvalidCCVVersion(versionTag(), versionPrefix); // The attested version is the first 4 bytes of the hook data, which occupies the last 36 bytes of the CCTP message. // We exclude the last 32 bytes of the hook data, which contains the message ID, to get the version. bytes4 attestedVersion = bytes4(verifierResults[VERIFIER_VERSION_START:VERIFIER_VERSION_START + VERIFIER_VERSION_SIZE]); if (attestedVersion != versionTag()) revert InvalidCCVVersion(versionTag(), attestedVersion); // The attested message ID should match the hash passed into this function. // If not, there is a mismatch between what was attested and what was computed within this transaction. bytes32 messageId = bytes32(verifierResults[MESSAGE_ID_START:MESSAGE_ID_START + 32]); if (messageHash != messageId) revert InvalidMessageId(messageHash, messageId); Domain storage sourceDomain = s_chainToDomain[message.sourceChainSelector]; if (!sourceDomain.enabled) revert UnknownDomain(message.sourceChainSelector); // The attested sourceDomain must match the configured domainIdentifier for this chain selector. uint32 attestedSourceDomain = uint32(bytes4(verifierResults[SOURCE_DOMAIN_START:SOURCE_DOMAIN_START + 4])); if (attestedSourceDomain != sourceDomain.domainIdentifier) { revert InvalidSourceDomain(sourceDomain.domainIdentifier, attestedSourceDomain); } // The messageSender property of the messageBody must align with the allowedCallerOnSource. // This check is critical to ensure that CCIP is unable to process burn messages generated by other systems. bytes32 messageSender = bytes32(verifierResults[MESSAGE_SENDER_START:MESSAGE_SENDER_START + 32]); if (messageSender != sourceDomain.allowedCallerOnSource) { revert InvalidMessageSender(sourceDomain.allowedCallerOnSource, messageSender); } // Call into CCTP via the message transmitter proxy. // CCTP will validate signatures against the message before minting USDC. // Attestation occupies all bytes following the CCTP message. if (!i_messageTransmitterProxy.receiveMessage( verifierResults[VERIFIER_VERSION_SIZE:VERIFIER_VERSION_SIZE + CCTP_MESSAGE_SIZE], verifierResults[VERIFIER_VERSION_SIZE + CCTP_MESSAGE_SIZE:] )) { revert ReceiveMessageCallFailed(); } } // ================================================================ // │ Config │ // ================================================================ /// @notice Returns the static configuration. /// @return tokenMessenger The address of the token messenger. /// @return messageTransmitterProxy The address of the message transmitter proxy. /// @return usdcToken The address of the USDC token. /// @return localDomainIdentifier The local domain identifier. function getStaticConfig() external view returns (address tokenMessenger, address messageTransmitterProxy, address usdcToken, uint32 localDomainIdentifier) { return ( address(i_tokenMessenger), address(i_messageTransmitterProxy), address(i_usdcToken), i_localDomainIdentifier ); } /// @notice Returns the dynamic configuration. /// @return dynamicConfig The dynamic configuration. function getDynamicConfig() external view returns (DynamicConfig memory dynamicConfig) { return s_dynamicConfig; } /// @notice Sets the dynamic configuration. /// @param dynamicConfig The dynamic configuration. /// @dev FeeTokenHandler will revert if feeAggregator is zero when withdrawing fees. /// @dev A zero address fee aggregator is valid, and intentionally reverts calls to withdraw fee tokens. function setDynamicConfig( DynamicConfig memory dynamicConfig ) external onlyOwner { _setDynamicConfig(dynamicConfig); } /// @notice Sets the dynamic configuration. /// @param dynamicConfig The dynamic configuration. /// @dev FeeTokenHandler will revert if feeAggregator is zero when withdrawing fees. /// @dev A zero address fee aggregator is valid, and intentionally reverts calls to withdraw fee tokens. function _setDynamicConfig( DynamicConfig memory dynamicConfig ) private { if (dynamicConfig.fastFinalityBps == 0 || dynamicConfig.fastFinalityBps > BPS_DIVIDER) { revert InvalidFastFinalityBps(dynamicConfig.fastFinalityBps); } s_dynamicConfig = dynamicConfig; emit DynamicConfigSet(dynamicConfig); } /// @notice Gets the CCTP domain for a given CCIP chain selector. /// @param chainSelector The CCIP chain selector corresponding to the domain. /// @return domain The CCTP domain corresponding to the given chain selector. function getDomain( uint64 chainSelector ) external view returns (Domain memory) { return s_chainToDomain[chainSelector]; } /// @notice Sets the CCTP domain for a CCIP chain selector. /// @param domains The array of SetDomainArgs structs to set. /// @dev Must validate mapping of selectors -> (domain, caller) prior to calling this function. function setDomains( SetDomainArgs[] calldata domains ) external onlyOwner { for (uint256 i = 0; i < domains.length; ++i) { SetDomainArgs memory domain = domains[i]; if ( domain.allowedCallerOnDest == bytes32(0) || domain.allowedCallerOnSource == bytes32(0) || domain.chainSelector == 0 ) { revert InvalidSetDomainArgs(domain); } s_chainToDomain[domain.chainSelector] = Domain({ allowedCallerOnDest: domain.allowedCallerOnDest, allowedCallerOnSource: domain.allowedCallerOnSource, mintRecipientOnDest: domain.mintRecipientOnDest, domainIdentifier: domain.domainIdentifier, enabled: domain.enabled }); } emit DomainsSet(domains); } /// @notice Updates remote chain configurations. /// @param remoteChainConfigArgs Array of destination chain configurations. function applyRemoteChainConfigUpdates( RemoteChainConfigArgs[] calldata remoteChainConfigArgs ) external onlyOwner { _applyRemoteChainConfigUpdates(remoteChainConfigArgs); } /// @notice Updates senders that are allowed to use this verifier. /// @param allowlistConfigArgsItems Array of AllowListConfigArgs, where each item is for a destChainSelector. function applyAllowlistUpdates( AllowlistConfigArgs[] calldata allowlistConfigArgsItems ) external { if (msg.sender != owner()) { if (msg.sender != s_dynamicConfig.allowlistAdmin) { revert OnlyCallableByOwnerOrAllowlistAdmin(); } } _applyAllowlistUpdates(allowlistConfigArgsItems); } /// @notice Sets the finality config according to the FinalityCodec library encoding. /// @param allowedFinality The finality settings allowed by this verifier. function setAllowedFinalityConfig( bytes4 allowedFinality ) external onlyOwner { _setAllowedFinalityConfig(allowedFinality); } /// @notice Updates the storage location identifiers. /// @param newLocations The new storage location identifiers. function updateStorageLocations( string[] memory newLocations ) external onlyOwner { _setStorageLocations(newLocations); } // ================================================================ // │ Fees │ // ================================================================ /// @notice Withdraws the outstanding fee token balances to the fee aggregator. /// @dev This function can be permissionless as just transfers tokens to a trusted address. /// @param feeTokens The fee tokens to withdraw. function withdrawFeeTokens( address[] calldata feeTokens ) external { FeeTokenHandler._withdrawFeeTokens(feeTokens, s_dynamicConfig.feeAggregator); } }