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.
244 lines
10 KiB
Solidity
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);
|
|
}
|
|
}
|