aere-contracts/contracts/stablecoins/AereHypERC20Synthetic.sol
Aere Network a13a649b77
Some checks are pending
contracts-ci / Install (lockfile) → compile → full test suite (push) Waiting to run
contracts-ci / PQC known-answer tests (NIST vectors) (push) Waiting to run
contracts-ci / Coverage (scoped, with artifacts) (push) Waiting to run
Initial public release
Aere Network public source. Everything here can be checked against the live
chain (chain id 2800, https://rpc.aere.network).

Scope note, stated up front rather than buried: consensus on chain 2800 is
classical secp256k1 ECDSA QBFT. The post-quantum work in this repository is at
the signature, precompile, account and transport layers. Nothing here makes the
consensus post-quantum, and no document in it should be read as claiming so.
2026-07-20 01:02:37 +03:00

244 lines
10 KiB
Solidity

// 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);
}
}