Some checks failed
contracts-ci / Install (lockfile) → compile → full test suite (push) Has been cancelled
contracts-ci / Ethereum interop (EIP-2537 BLS, prague hardfork) (push) Has been cancelled
contracts-ci / PQC known-answer tests (NIST vectors) (push) Has been cancelled
contracts-ci / Coverage (scoped, with artifacts) (push) Has been cancelled
The published line and the local line had no common ancestor: the public one carried the redaction pass, the local one carried three weeks of corrections that never shipped. This commit ports the local work onto the public line, keeps every public redaction, and extends the same discretion to seven client mentions that were still named in published comments. Carried: LICENSE year and LICENSING.md; the measured burn figures replacing the deflation claim (the vault holds ~0.137 AERE of 2.8 billion, and burn is a share of validator coinbase revenue, which is zero today); 'audited' removed from next to Bouncy Castle; citation paths rewritten to published form with CITATIONS-UNRESOLVED.md remeasured 2026-08-11; VERIFY-POLICY.md; slashing and ownership comments brought down to what the code does; the AerePyth repair; the shutter test helper the tests cite; runnable package.json entries; the CI file split into a GitHub/Gitea twin pair with a real measured test-run status; and the .gitignore hardening written after a compiled artifact leaked a local path in a sibling repository. A false '2-of-3 multisig' description of the owner account is corrected to what the chain measures: an externally owned account. The self-audit findings catalog stays unpublished pending an explicit decision.
245 lines
10 KiB
Solidity
245 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 (the Foundation account, a single-key EOA today, not a
|
|
* multisig) 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);
|
|
}
|
|
}
|