// SPDX-License-Identifier: MIT pragma solidity 0.8.23; import "@openzeppelin/contracts/access/Ownable.sol"; import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; import "./IHypMailbox.sol"; interface IUSDCeMintable { function mint(address to, uint256 amount) external; function burnFrom(address from, uint256 amount) external; function decimals() external view returns (uint8); } /** * @title AereHypERC20Synthetic — AERE-side Hyperlane Warp Route endpoint * for USDC.e (and other Phase-1 wrapped * stablecoins). * * @notice One synthetic deployed per bridged asset (USDC.e Q3 2026, USDT.e Q4, * USDe.e Q3-4, EURC.e Q4 per immediate_build_plan). Each synthetic * knows its sister "Collateral" contract on Ethereum mainnet and the * Hyperlane Mailbox + interchain gas paymaster on AERE. * * @dev Message format (matches Hyperlane HypERC20 convention): * bytes32(recipient) | uint256(amount) * * Inbound: * Mailbox calls handle(originDomain, senderBytes32, body). Body is * parsed; mint() called on USDC.e to recipient with amount. * * Outbound: * User holds USDC.e and calls transferRemote(destDomain, recipient, * amount). The synthetic burns the user's USDC.e and dispatches a * message to the trusted-router on the destination chain, which * releases the locked USDC. * * Trusted-router enrollment: * Owner (Foundation 2-of-3) registers exactly ONE router per remote * domain. The address is bytes32-padded as required by Hyperlane. * Handle() rejects any sender not in the enrolment. * * IGP fees: * transferRemote is payable. msg.value MUST cover the Hyperlane * IGP quote for the destination. Excess is refunded to caller. * * No admin pause / no upgrade proxy: the synthetic itself is * immutable except for trusted-router enrolment (an admittedly * centralised choice; consistent with Hyperlane's reference * design). Router rotation has no timelock at Phase 1. * * Phase-1 contract — vendoring the canonical Hyperlane HypERC20 * logic would import the whole at-hyperlane-xyz/core dep tree; * we conform to the same wire protocol but implement the * minimal subset here. */ contract AereHypERC20Synthetic is Ownable, ReentrancyGuard, IHypMessageRecipient { using TypeCasts for address; using TypeCasts for bytes32; /* --------------------------------- immutables --------------------------------- */ IHypMailbox public immutable MAILBOX; IUSDCeMintable public immutable TOKEN; // USDC.e (or analogous Phase-1 wrapped stable) /* ----------------------------------- state ------------------------------------ */ /// @notice domain id → trusted remote router (bytes32-padded address) 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, bytes32 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 RefundFailed(); /* --------------------------------- modifiers --------------------------------- */ modifier onlyMailbox() { if (msg.sender != address(MAILBOX)) revert NotMailbox(); _; } /* -------------------------------- constructor ------------------------------- */ uint8 public immutable EXPECTED_DECIMALS; /// AUDIT FIX (HIGH #17): require explicit decimals at deploy; constructor /// reverts if the local TOKEN.decimals() doesn't match. Phase 2 enrollment /// MUST also pin remote decimals in the router handshake (off-chain). constructor(address mailbox, address token, uint8 expectedDecimals) { require(mailbox != address(0) && token != address(0), "zero address"); MAILBOX = IHypMailbox(mailbox); TOKEN = IUSDCeMintable(token); // probe token decimals; fail loudly if mismatch. (bool ok, bytes memory data) = token.staticcall(abi.encodeWithSignature("decimals()")); require(ok && data.length >= 32, "decimals-probe-failed"); require(uint8(abi.decode(data, (uint256))) == expectedDecimals, "decimals-mismatch"); EXPECTED_DECIMALS = expectedDecimals; } /* ------------------------------- router enroll ------------------------------ */ /// AUDIT FIX (HIGH #16): 7-day timelock on router enrollment. Foundation /// proposes; after delay, anyone can finalise. Prevents same-block hijack /// of bridge to attacker-controlled remote router. uint64 public constant ROUTER_ENROLL_DELAY = 7 days; mapping(uint32 => bytes32) public pendingRouter; mapping(uint32 => uint64) public pendingRouterReadyAt; event RouterEnrollProposed(uint32 indexed domain, bytes32 router, uint64 readyAt); error RouterEnrollDelayOpen(uint64 nowTs, uint64 earliest); error NoPendingRouter(); function proposeEnrollRouter(uint32 domain, bytes32 router) external onlyOwner { pendingRouter[domain] = router; pendingRouterReadyAt[domain] = uint64(block.timestamp + ROUTER_ENROLL_DELAY); emit RouterEnrollProposed(domain, router, pendingRouterReadyAt[domain]); } function finalizeEnrollRouter(uint32 domain) external { uint64 readyAt = pendingRouterReadyAt[domain]; if (readyAt == 0) revert NoPendingRouter(); if (block.timestamp < readyAt) revert RouterEnrollDelayOpen(uint64(block.timestamp), readyAt); routers[domain] = pendingRouter[domain]; emit RouterEnrolled(domain, pendingRouter[domain]); delete pendingRouter[domain]; delete pendingRouterReadyAt[domain]; } function cancelEnrollRouter(uint32 domain) external onlyOwner { if (pendingRouterReadyAt[domain] == 0) revert NoPendingRouter(); delete pendingRouter[domain]; delete pendingRouterReadyAt[domain]; } function unenrollRouter(uint32 domain) external onlyOwner { delete routers[domain]; emit RouterEnrolled(domain, bytes32(0)); } /* ----------------------------------- outbound ------------------------------- */ /** * @notice Burn `amount` of TOKEN from the caller and dispatch a Hyperlane * message instructing the destination domain's enrolled router to * release the corresponding native asset to `recipient`. * @param destination Hyperlane domain id of the remote chain. * @param recipient bytes32-padded address that will receive on the remote. * @param amount Token units to send (USDC.e has 6 decimals). * @return messageId The dispatched Hyperlane message hash. */ 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); // Encode the transfer payload — matches Hyperlane HypERC20 wire format. bytes memory body = abi.encodePacked(recipient, amount); // Quote and verify the IGP fee. uint256 fee = MAILBOX.quoteDispatch(destination, remoteRouter, body); if (msg.value < fee) revert InsufficientIgpPayment(msg.value, fee); // Burn the caller's tokens BEFORE the cross-chain dispatch. TOKEN.burnFrom(msg.sender, amount); // Dispatch via Mailbox with the exact fee. messageId = MAILBOX.dispatch{value: fee}(destination, remoteRouter, body); // Refund any over-payment. if (msg.value > fee) { (bool ok, ) = msg.sender.call{value: msg.value - fee}(""); if (!ok) revert RefundFailed(); } emit SentTransferRemote(destination, recipient, amount, messageId); } /* ----------------------------------- inbound -------------------------------- */ /** * @notice Called by the Mailbox after the configured ISM has verified the * message from `origin`. Parses [bytes32 recipient | uint256 amount] * and mints the corresponding USDC.e to the recipient on AERE. */ 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); // Parse [bytes32(recipient) | uint256(amount)] = 64 bytes 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(); TOKEN.mint(recipient, amount); emit ReceivedTransferRemote(origin, recipientB32, 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); } }