// SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {BaseAdapter} from "../BaseAdapter.sol"; import {AgentRegistry} from "../AgentRegistry.sol"; interface IERC20 { function approve(address spender, uint256 amount) external returns (bool); function balanceOf(address owner) external view returns (uint256); } /// Circle TokenMessengerV2. Fast Transfer burns USDC here and mints it on the /// destination domain. `depositForBurnWithHook` exists too and takes arbitrary /// `hookData` executed on the far side — this adapter never calls it, and /// there is no field that could reach it. interface ITokenMessengerV2 { function depositForBurn( uint256 amount, uint32 destinationDomain, bytes32 mintRecipient, address burnToken, bytes32 destinationCaller, uint256 maxFee, uint32 minFinalityThreshold ) external; /// Where this messenger settles. Asked of the messenger rather than /// configured, so the claim cannot reach a transmitter the burn did not. function localMessageTransmitter() external view returns (address); } /// Circle MessageTransmitterV2. The far side of a crossing: it verifies Circle's /// attestation over a message and mints to the recipient the BURN fixed. /// /// NOT A VENUE OF ITS OWN, and deliberately. Adding one would mean every live /// account needs a re-install before it could claim, and a venue table that has /// gone stale points an account at an address that answers empty rather than /// reverting. The TokenMessenger already names its own transmitter, so the /// address is asked for at the moment it is used and cannot drift from the /// messenger the burn actually went through. interface IMessageTransmitterV2 { function receiveMessage(bytes calldata message, bytes calldata attestation) external returns (bool); } /// @title CctpAdapter — send USDC to the same account on another chain, via CCTP V2 Fast. /// /// TokenMessengerV2.depositForBurn takes seven arguments. Four of them are /// things this design exists to keep out of an intent: a mint recipient, a /// destination caller (the only address that may complete the mint), a burn /// token, and a finality threshold. Those are PINNED here. What is signed is /// the same shape as `AcrossAdapter.Cross`: the destination is a chain id, /// not a CCTP domain, and a fee ceiling, because Fast takes `maxFee` out of /// the burn. /// /// THE RECIPIENT IS NOT A PARAMETER. The account is CREATE2-derived from its /// owner and the agent grant, so it has the SAME address on every served chain. /// `mintRecipient` is that address, left-padded to bytes32. An adapter that /// took a recipient could send the account's USDC to any address on any CCTP /// domain, which is the arbitrary transfer this design removes. /// /// `destinationCaller` is THE ACCOUNT ITSELF, and was `bytes32(0)` until /// 2026-09-21. It is the only address that may `receiveMessage` on the far /// side, so this makes completing a crossing an act only its owner's account /// can perform — paired with OP_CLAIM, which is how the account performs it. /// /// WHAT ZERO WAS AVOIDING, and why it is no longer the right trade. A non-zero /// caller that is SOMEBODY ELSE can ransom a burn: they alone may complete it /// and may decline to. That reasoning does not reach the account, which is the /// beneficiary — it cannot hold itself hostage. /// /// WHAT ZERO COST INSTEAD. The old comment read "Zero lets Circle's /// permissionless attesters complete it", and that is one word too far: zero /// means anyone MAY, not that anyone WILL. Circle attests and does not relay, /// so every crossing rested attested and unspent until something called /// receiveMessage — and nothing did. Measured on production 2026-09-21: /// 999,870 USDC on Arbitrum, attested within a minute of the burn, unclaimed /// until it was completed by hand. /// /// WHAT THIS COSTS, stated because it is the real trade. The account must /// EXIST on the destination chain before the crossing can be completed, and a /// counterfactual account has no code and can call nothing. Under zero, the /// money reached the account's address whatever happened afterwards and anyone /// could finish it; under this, a destination where the account cannot be /// deployed is a destination where the burn cannot be completed at all. The /// account is deployable permissionlessly through the factory, so the /// requirement is that the account LAYER is there — which on 2026-09-21 it was /// not, and the agent validator had to be published before arbitrum could /// derive accounts at all. /// /// CCTP moves USDC. `burnToken` is the canonical USDC on this chain, and the /// signed `asset` must equal it. A signed WETH would still charge whichever /// cap row it named if we didn't check. /// /// `minFinalityThreshold` is 1000 (Circle's Fast). 2000 is Standard, ~15 /// minutes. Not signed: a keeper choosing Standard after the planner quoted /// Fast is a liveness lie, and a keeper choosing 0 is undefined. /// /// A burn is FINAL on this side the moment it lands. Nothing here can undo /// it, and what is minted on the far side is Circle's attestation later — /// which is why this step never publishes to the runtime bus. /// /// `_requiresArming` stays false, matching `BridgeAdapter` and `AcrossAdapter`. contract CctpAdapter is BaseAdapter { bytes32 public constant OP_BRIDGE = "bridge"; /// Completing a crossing on the DESTINATION chain. /// /// A SEPARATE OPERATION FROM "bridge", so an account may be authorised to /// finish crossings without being authorised to start them. They are /// different acts with different risk: bridge sends this account's money /// away, claim brings somebody's money in and can move nothing. bytes32 public constant OP_CLAIM = "claim"; /// The most any crossing may pay, whatever the intent says. 100 bps against /// the typical CCTP Fast fee, so it binds rather than decorates. uint16 public constant MAX_BRIDGE_FEE_BPS = 100; /// Circle's Fast Transfer threshold. Greater than 2000 is interpreted as /// Standard; 1000 is Fast. Pinned, not signed. uint32 public constant FAST_FINALITY = 1000; bytes32 internal constant BURN_TYPEHASH = keccak256( "Burn(address account,address owner,bytes32 taskId,uint256 deadline,address asset,uint256 amount,address amountFrom,bytes32 amountFromTask,uint256 destinationChainId,uint16 maxFeeBps,bytes32 venue)" ); /// WHAT THE ACCOUNT SIGNS TO CLAIM, and what it deliberately does not. /// /// The message and the attestation are hashed into the digest, not carried /// in it — they are hundreds of bytes and Circle produces them only after /// the burn, so a plan authored beforehand could not name them. The claim /// is signed at execution time like any other step, over the exact bytes /// that will be sent. bytes32 internal constant CLAIM_TYPEHASH = keccak256( "Claim(address account,address owner,bytes32 taskId,uint256 deadline,bytes32 messageHash,bytes32 attestationHash,bytes32 venue)" ); struct Claim { address account; address owner; bytes32 taskId; uint256 deadline; /// Circle's message and its attestation, verbatim. Nothing here parses /// them: the attestation signs those exact bytes, so one edited byte is /// a signature Circle's own transmitter refuses. bytes message; bytes attestation; /// The CCTP venue — the TokenMessenger — resolved against what the /// owner installed. The transmitter is read FROM it rather than named /// here, so a claim cannot be pointed at a contract nobody approved. bytes32 venue; } struct Burn { address account; /// WHOSE account. Signed and checked against what the account itself /// says, so a plan that named the wrong one is refused rather than /// authorised by a signature that happens to be valid for it. address owner; bytes32 taskId; uint256 deadline; address asset; uint256 amount; /// WHERE THE AMOUNT COMES FROM. Zero means the amount above. See /// BaseAdapter. address amountFrom; bytes32 amountFromTask; /// Where it is going, as a CHAIN ID. Translated to a CCTP domain here, /// never taken as one — a raw domain could name a chain this system /// has no account on. uint256 destinationChainId; /// The most this crossing may pay CCTP Fast, in bps of `amount`. Zero /// is refused: a plan that forgot the field is a plan nobody checked. /// The adapter takes the tighter of this and `MAX_BRIDGE_FEE_BPS`. uint16 maxFeeBps; /// WHICH TokenMessenger, by name. Resolved to an address from what /// the owner installed, never taken as one. bytes32 venue; } /// A crossing completed on this chain. The message hash rather than the /// message: the bytes are large, and the hash is what identifies it in /// Circle's own accounting. event Claimed(address indexed account, address indexed transmitter, bytes32 indexed messageHash); event Bridged( address indexed account, address indexed asset, uint256 amount, uint256 indexed destinationChainId, uint256 maxFee ); error ApproveFailed(); error SameChain(uint256 chainId); /// A destination this system has not proven account-address parity for, so /// `b.account` there is not this owner's account. error ChainNotServed(uint256 chainId); /// Raised when what left the account is not what was authorised — in /// EITHER direction. Too much is theft; nothing at all is a crossing that /// did not happen, reported as one that did. error SentMoreThanAuthorised(uint256 authorised, uint256 sent); /// A signed zero fee bound. Defaulting to the ceiling would let a plan /// that forgot the field through as "pay the maximum". error NoFeeBound(); /// CCTP moves USDC. A signed asset that is not the canonical USDC on this /// chain is refused rather than burned as something Circle will not mint. error NotUsdc(address asset); /// The account's CCTP venue names no transmitter. A call to address(0) /// would succeed silently and mint nothing, so it is refused here. error NoTransmitter(address messenger); /// receiveMessage returned false rather than reverting. error ClaimRefused(); /// The chains this system derives accounts on, and therefore the only /// chains a bridge may deliver to. /// /// Duplicated from `BridgeAdapter` / `AcrossAdapter` rather than lifted /// into `BaseAdapter` — touching `BaseAdapter` would move all seventeen /// adapter addresses. A test holds the three lists against each other. /// /// 1 ethereum · 8453 base · 42161 arbitrum. function _servesChain(uint256 chainId) internal pure returns (bool) { return chainId == 1 || chainId == 8453 || chainId == 42161; } function servesChain(uint256 chainId) external pure returns (bool) { return _servesChain(chainId); } constructor(AgentRegistry registry_) BaseAdapter(registry_) {} function _domainNameAndVersion() internal pure override returns (string memory, string memory) { return ("DeFiLoopsCctpAdapter", "1"); } function digest(Burn calldata b) public view returns (bytes32) { return _hashTypedData( keccak256( abi.encode( BURN_TYPEHASH, b.account, b.owner, b.taskId, b.deadline, b.asset, b.amount, b.amountFrom, b.amountFromTask, b.destinationChainId, b.maxFeeBps, b.venue ) ) ); } function burn(Burn calldata b, bytes calldata signature) external { _consume(b.account, b.owner, b.taskId, b.deadline, digest(b), signature); _requireOperation(b.account, OP_BRIDGE); // CONSUMES AND NEVER PUBLISHES. CCTP value arrives on ANOTHER chain // in a later transaction, and transient storage is scoped to one // transaction on one chain. uint256 amount = _amountToSpend(b.account, b.amount, b.amountFrom, b.amountFromTask, b.asset); if (b.asset != _usdc(block.chainid)) revert NotUsdc(b.asset); _charge(b.account, b.asset, amount); if (b.destinationChainId == block.chainid) revert SameChain(b.destinationChainId); if (!_servesChain(b.destinationChainId)) revert ChainNotServed(b.destinationChainId); uint256 maxFee = _maxFee(amount, b.maxFeeBps); address venue = _venueOf(b.account, b.venue); uint256 before = IERC20(b.asset).balanceOf(b.account); bytes memory ok = _exec(b.account, b.asset, 0, abi.encodeCall(IERC20.approve, (venue, amount))); if (ok.length != 0 && !abi.decode(ok, (bool))) revert ApproveFailed(); _depositForBurn(b.account, venue, b.asset, amount, maxFee, _domain(b.destinationChainId)); _requireNoResidual(b.account, b.asset, venue); uint256 sent = before - IERC20(b.asset).balanceOf(b.account); if (sent != amount) revert SentMoreThanAuthorised(amount, sent); emit Bridged(b.account, b.asset, amount, b.destinationChainId, maxFee); } function claimDigest(Claim calldata c) public view returns (bytes32) { return _hashTypedData( keccak256( abi.encode( CLAIM_TYPEHASH, c.account, c.owner, c.taskId, c.deadline, keccak256(c.message), keccak256(c.attestation), c.venue ) ) ); } /// Complete a crossing that was burned on another chain. /// /// # Why this needs almost no bounds, unlike burn /// /// `burn` is hedged everywhere — the recipient is not a parameter, the /// destination caller is forced to zero, the fee is capped by a constant — /// because it SENDS this account's money and every field is a way to send /// it somewhere else. /// /// This one can send nothing. `receiveMessage` mints to the recipient the /// BURN fixed, on a message Circle has signed; the bytes are not ours to /// choose, because a message we edited is a signature that does not verify /// and a message somebody else's burn produced mints to THEIR recipient. /// The worst a fully compromised agent achieves here is spending gas /// completing a stranger's crossing for them. /// /// So the checks are the two that are actually load-bearing: the operation /// is authorised, and the transmitter is the one the account's own CCTP /// venue settles through. /// /// # It publishes nothing /// /// Matching `burn`, and for a sharper reason: the USDC minted here lands in /// the account's balance, and a later step that wants to spend it reads a /// balance rather than a bus. The mint is not this transaction's output in /// any sense the runtime bus models. function claim(Claim calldata c, bytes calldata signature) external { _consume(c.account, c.owner, c.taskId, c.deadline, claimDigest(c), signature); _requireOperation(c.account, OP_CLAIM); address messenger = _venueOf(c.account, c.venue); address transmitter = ITokenMessengerV2(messenger).localMessageTransmitter(); if (transmitter == address(0)) revert NoTransmitter(messenger); bytes memory out = _exec( c.account, transmitter, 0, abi.encodeCall(IMessageTransmitterV2.receiveMessage, (c.message, c.attestation)) ); // A FALSE RETURN IS A FAILURE THAT DID NOT REVERT. receiveMessage // returns bool; empty returndata must not count as success — a step // that reported success over an unminted crossing would spend a task // with no USDC minted. if (out.length != 32 || !abi.decode(out, (bool))) revert ClaimRefused(); emit Claimed(c.account, transmitter, keccak256(c.message)); } /// TWO BOUNDS, AND THE AGENT IS INSIDE BOTH. `maxFeeBps` is a number the /// agent fills in, and on the production path `_consume` verifies no /// signature, so the constant is the only half a compromised key cannot /// author. Returned in USDC wei: that is the unit TokenMessengerV2 takes. function _maxFee(uint256 amount, uint16 maxFeeBps) internal pure returns (uint256) { if (maxFeeBps == 0) revert NoFeeBound(); uint16 feeBps = maxFeeBps < MAX_BRIDGE_FEE_BPS ? maxFeeBps : MAX_BRIDGE_FEE_BPS; return (amount * feeBps) / 10_000; } /// CCTP domain for a served chain. Domain 0 is Ethereum and is valid — /// do not treat 0 as unset. Called only after `_servesChain`. function _domain(uint256 chainId) internal pure returns (uint32) { if (chainId == 1) return 0; if (chainId == 8453) return 6; if (chainId == 42161) return 3; revert ChainNotServed(chainId); } function _usdc(uint256 c) internal pure returns (address) { if (c == 1) return 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; if (c == 8453) return 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913; if (c == 42161) return 0xaf88d065e77c8cC2239327C5EDb3A432268e5831; return address(0); } /// Isolated so `burn` does not run out of stack: `via_ir` is pinned off. function _depositForBurn( address account, address venue, address asset, uint256 amount, uint256 maxFee, uint32 destinationDomain ) internal { _exec( account, venue, 0, abi.encodeCall( ITokenMessengerV2.depositForBurn, ( amount, destinationDomain, bytes32(uint256(uint160(account))), asset, // destinationCaller: THE ACCOUNT ITSELF. See the note above // on why this is no longer zero. bytes32(uint256(uint160(account))), maxFee, FAST_FINALITY ) ) ); } }