// 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 "./IHypMailbox.sol"; /** * @title AereHypERC20Collateral — Ethereum-mainnet lockbox for Phase-1 * wrapped stablecoins bridged to AERE. * * @notice Deployed on Ethereum mainnet (chain 1) per bridged asset: * - USDC mainnet → AereHypERC20Collateral(USDC) ↔ USDC.e on AERE * - USDT mainnet → AereHypERC20Collateral(USDT) ↔ USDT.e on AERE * - USDe mainnet → AereHypERC20Collateral(USDe) ↔ USDe.e on AERE * - EURC mainnet → AereHypERC20Collateral(EURC) ↔ EURC.e on AERE * * @dev Outbound (lock + dispatch): * User approves N native-USDC and calls transferRemote(destDomain=2800, * recipient, N). Contract pulls N USDC into its lockbox, dispatches a * Hyperlane message to the AERE synthetic router with [recipient | N]. * * Inbound (release): * Mailbox calls handle(originDomain=2800, senderBytes32, body). Body is * [recipient | amount]. Contract releases `amount` of native USDC to * `recipient`. Sender must match the enrolled AERE router. * * Reserve invariant: the contract's native-USDC balance equals the * outstanding USDC.e supply on AERE (modulo in-flight messages). The * AereNavOracle (Q3 2026) snapshots both sides nightly for public * attestation. * * Trusted-router enrolment, IGP fees, message format and admin model * mirror the AERE-side synthetic exactly. */ contract AereHypERC20Collateral is Ownable, ReentrancyGuard, IHypMessageRecipient { using TypeCasts for address; using TypeCasts for bytes32; /* --------------------------------- immutables --------------------------------- */ IHypMailbox public immutable MAILBOX; IERC20 public immutable COLLATERAL_TOKEN; // native USDC / USDT / USDe / EURC /* ----------------------------------- state ------------------------------------ */ mapping(uint32 => bytes32) public routers; /* ----------------------------------- events ----------------------------------- */ event RouterEnrolled(uint32 indexed domain, bytes32 indexed 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 InsufficientIgpPayment(uint256 paid, uint256 required); error ZeroAmount(); error ZeroRecipient(); error PullFailed(); error PushFailed(); error RefundFailed(); /* --------------------------------- modifiers --------------------------------- */ modifier onlyMailbox() { if (msg.sender != address(MAILBOX)) revert NotMailbox(); _; } /* -------------------------------- constructor ------------------------------- */ constructor(address mailbox, address collateralToken) { require(mailbox != address(0) && collateralToken != address(0), "zero address"); MAILBOX = IHypMailbox(mailbox); COLLATERAL_TOKEN = IERC20(collateralToken); } /* ------------------------------- router enroll ------------------------------ */ function enrollRouter(uint32 domain, bytes32 router) external onlyOwner { routers[domain] = router; emit RouterEnrolled(domain, router); } function unenrollRouter(uint32 domain) external onlyOwner { delete routers[domain]; emit RouterEnrolled(domain, bytes32(0)); } /* ----------------------------------- outbound ------------------------------- */ /** * @notice Lock `amount` of COLLATERAL_TOKEN from caller and dispatch a * mint instruction to the enrolled router on the destination. */ 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); uint256 fee = MAILBOX.quoteDispatch(destination, remoteRouter, body); if (msg.value < fee) revert InsufficientIgpPayment(msg.value, fee); // Lock the collateral. bool pulled = COLLATERAL_TOKEN.transferFrom(msg.sender, address(this), amount); if (!pulled) revert PullFailed(); messageId = MAILBOX.dispatch{value: fee}(destination, remoteRouter, body); if (msg.value > fee) { (bool ok, ) = msg.sender.call{value: msg.value - fee}(""); if (!ok) revert RefundFailed(); } emit SentTransferRemote(destination, recipient, amount, messageId); } /* ----------------------------------- inbound -------------------------------- */ function handle( uint32 origin, bytes32 sender, bytes calldata body ) external payable override onlyMailbox { 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(); bool ok = COLLATERAL_TOKEN.transfer(recipient, amount); if (!ok) revert PushFailed(); emit ReceivedTransferRemote(origin, recipient, amount); } /* ----------------------------------- views ---------------------------------- */ function quoteDispatchFee( 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 Lockbox balance — should equal outstanding USDC.e supply on AERE /// (modulo in-flight Hyperlane messages). function lockedSupply() external view returns (uint256) { return COLLATERAL_TOKEN.balanceOf(address(this)); } }