// SPDX-License-Identifier: MIT pragma solidity 0.8.23; import "@openzeppelin/contracts/access/Ownable.sol"; import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "../stablecoins/IHypMailbox.sol"; /** * @title AereWarpRouteV3 * @notice Hyperlane v3 TokenRouter for USDC.e on AERE chain 2800, bound to the * WORKING AereMailboxV3 (0x7BF113Ab1BCd2b6da01804764065776e3057605a) and * its default post-dispatch hook AereIGPV3 * (0x5e4B8e9b196B1c7b3Be86148769aB0047c79744c). * * This REPLACES the dead legacy path. The prior AereWarpRoute pointed at * AereMessenger, whose runtime bytecode has NO quoteDispatch selector, so * every quote reverted and the route was unusable. AereMailboxV3 exposes * a real quoteDispatch(uint32,bytes32,bytes) and routes the quoted fee to * AereIGPV3 internally on dispatch, so this router works end to end on the * AERE side. * * TOKEN HANDLING — two immutable modes, chosen at deploy: * * Mode.MintBurn (canonical synthetic; USDC.e is the bridged representation): * - transferRemote burns the caller's USDC.e (TOKEN.burnFrom), then dispatches. * - handle mints USDC.e to the recipient (TOKEN.mint). * Use this ONLY when the existing USDC.e grants THIS router mint/burn rights * (i.e. its immutable BRIDGE == this router). No NEW token is minted here; * USDC.e is the existing bridged-collateral representation, 1:1 backed by the * native USDC locked in the sister AereHypERC20Collateral on Ethereum. * * Mode.Lock (lock/release against an existing token this router does NOT * control the mint of): * - transferRemote pulls (locks) the existing token via safeTransferFrom. * - handle releases the locked token via safeTransfer to the recipient. * * WIRE FORMAT: Hyperlane HypERC20 convention — body = abi.encodePacked( * bytes32 recipient, uint256 amount) = exactly 64 bytes. * * V3 FEE FLOW: dispatch() forwards the quoted fee to the mailbox's default hook * (the IGP). Callers pay the fee as msg.value to transferRemote; anything above * the quote is refunded to the caller. quoteDispatch on the mailbox already * includes the IGP hook price, so this router quotes against the mailbox (the * canonical v3 flow) and keeps an immutable IGP reference only for the * transparency view quoteGasPayment(). * * NO new ERC-20 / wrapped / governance token is introduced by this contract. */ contract AereWarpRouteV3 is Ownable, ReentrancyGuard, IHypMessageRecipient { using SafeERC20 for IERC20; using TypeCasts for address; using TypeCasts for bytes32; enum Mode { Lock, MintBurn } /* --------------------------------- immutables --------------------------------- */ /// @notice AereMailboxV3 — dispatch + quoteDispatch endpoint on chain 2800. IHypMailbox public immutable MAILBOX; /// @notice AereIGPV3 — the mailbox default post-dispatch hook (fee sink). /// Referenced for the quoteGasPayment() transparency view only; the /// authoritative fee for a dispatch is MAILBOX.quoteDispatch(...). IAereIGPV3View public immutable IGP; /// @notice Existing USDC.e representation on AERE (or the token this router /// locks/releases). NEVER a token minted by this contract's deploy. IERC20 public immutable TOKEN; /// @notice Lock (release existing) or MintBurn (mint/burn existing USDC.e). Mode public immutable MODE; /* ----------------------------------- state ------------------------------------ */ /// @notice Hyperlane domain id => trusted remote router (bytes32-padded address). mapping(uint32 => bytes32) public routers; uint256 public totalSentOut; uint256 public totalReceivedIn; /* ----------------------------------- events ----------------------------------- */ event RemoteRouterEnrolled(uint32 indexed domain, bytes32 router); event SentTransferRemote(uint32 indexed destination, bytes32 indexed recipient, uint256 amount, bytes32 messageId); event ReceivedTransferRemote(uint32 indexed origin, address indexed recipient, uint256 amount); /* ----------------------------------- errors ----------------------------------- */ error NotMailbox(); error UnknownRouter(uint32 origin, bytes32 sender); error NoRouterEnrolled(uint32 destination); error InsufficientGasPayment(uint256 paid, uint256 required); error ZeroAmount(); error ZeroRecipient(); error RefundFailed(); /* --------------------------------- modifiers ---------------------------------- */ modifier onlyMailbox() { if (msg.sender != address(MAILBOX)) revert NotMailbox(); _; } /* -------------------------------- constructor --------------------------------- */ /** * @param mailbox AereMailboxV3 address (must be non-zero). * @param igp AereIGPV3 address (mailbox default hook). May be zero if the * operator does not want the on-chain transparency view; the * dispatch fee path never depends on it. * @param token Existing USDC.e (MintBurn) or existing token to lock (Lock). * @param mode Lock or MintBurn. */ constructor(address mailbox, address igp, address token, Mode mode) { require(mailbox != address(0) && token != address(0), "zero address"); MAILBOX = IHypMailbox(mailbox); IGP = IAereIGPV3View(igp); TOKEN = IERC20(token); MODE = mode; } /* ------------------------------- router enroll -------------------------------- */ /// @notice Bind the single trusted remote router for a domain (owner-gated). function enrollRemoteRouter(uint32 domain, bytes32 router) external onlyOwner { routers[domain] = router; emit RemoteRouterEnrolled(domain, router); } /// @notice Remove the trusted remote router for a domain (owner-gated). function unenrollRemoteRouter(uint32 domain) external onlyOwner { delete routers[domain]; emit RemoteRouterEnrolled(domain, bytes32(0)); } /* ----------------------------------- outbound --------------------------------- */ /** * @notice Pull `amount` of TOKEN from the caller and dispatch a Hyperlane v3 * message instructing the enrolled router on `destination` to release * (or mint) the corresponding asset to `recipient`. * @param destination Hyperlane domain id of the remote chain. * @param recipient bytes32-padded recipient address on the remote chain. * @param amount Token units to send (USDC.e has 6 decimals). * @return messageId The dispatched Hyperlane v3 message id. */ function transferRemote( uint32 destination, bytes32 recipient, uint256 amount ) external payable nonReentrant returns (bytes32 messageId) { if (amount == 0) revert ZeroAmount(); if (recipient == bytes32(0)) revert ZeroRecipient(); bytes32 remoteRouter = routers[destination]; if (remoteRouter == bytes32(0)) revert NoRouterEnrolled(destination); bytes memory body = abi.encodePacked(recipient, amount); // Authoritative fee for the v3 dispatch. The mailbox forwards this to the // IGP hook internally, so we send exactly this as msg.value to dispatch(). uint256 fee = MAILBOX.quoteDispatch(destination, remoteRouter, body); if (msg.value < fee) revert InsufficientGasPayment(msg.value, fee); // Pull the caller's tokens BEFORE the cross-chain dispatch. if (MODE == Mode.MintBurn) { IWarpMintBurn(address(TOKEN)).burnFrom(msg.sender, amount); } else { TOKEN.safeTransferFrom(msg.sender, address(this), amount); } messageId = MAILBOX.dispatch{value: fee}(destination, remoteRouter, body); // Refund any msg.value above the quoted fee to the caller. if (msg.value > fee) { (bool ok, ) = msg.sender.call{value: msg.value - fee}(""); if (!ok) revert RefundFailed(); } totalSentOut += amount; emit SentTransferRemote(destination, recipient, amount, messageId); } /* ----------------------------------- inbound ---------------------------------- */ /** * @notice Called by AereMailboxV3 after its validator set verified the * message. Releases (Lock) or mints (MintBurn) `amount` to the * recipient parsed from the 64-byte Hyperlane body. */ function handle( uint32 origin, bytes32 sender, bytes calldata body ) external payable override onlyMailbox nonReentrant { bytes32 expected = routers[origin]; if (expected == bytes32(0) || expected != sender) revert UnknownRouter(origin, sender); require(body.length == 64, "invalid body length"); bytes32 recipientB32; uint256 amount; assembly { recipientB32 := calldataload(body.offset) amount := calldataload(add(body.offset, 32)) } address recipient = recipientB32.bytes32ToAddress(); if (MODE == Mode.MintBurn) { IWarpMintBurn(address(TOKEN)).mint(recipient, amount); } else { TOKEN.safeTransfer(recipient, amount); } totalReceivedIn += amount; emit ReceivedTransferRemote(origin, recipient, amount); } /* ------------------------------------ views ----------------------------------- */ /// @notice Fee that transferRemote(destination, recipient, amount) requires as /// msg.value, quoted against AereMailboxV3 (includes the IGP hook). function quoteTransferRemote( uint32 destination, bytes32 recipient, uint256 amount ) external view returns (uint256 fee) { bytes memory body = abi.encodePacked(recipient, amount); return MAILBOX.quoteDispatch(destination, routers[destination], body); } /// @notice Transparency view straight off AereIGPV3 for a given destination /// and gas limit. Reverts if IGP was not set at deploy. function quoteGasPayment(uint32 destination, uint256 gasLimit) external view returns (uint256) { return IGP.quoteGasPayment(destination, gasLimit); } /// @notice Locked balance held by this router (Lock mode reserve view). function lockedSupply() external view returns (uint256) { return TOKEN.balanceOf(address(this)); } } /* ------------------------------- helper interfaces -------------------------------- */ /// @notice USDC.e mint/burn surface (matches contracts/stablecoins/USDCe.sol). interface IWarpMintBurn { function mint(address to, uint256 amount) external; function burnFrom(address from, uint256 amount) external; } /// @notice AereIGPV3 quoting surface for the transparency view. interface IAereIGPV3View { function quoteGasPayment(uint32 destinationDomain, uint256 gasLimit) external view returns (uint256); }