aere-contracts/contracts/bridge/AereMailboxV3.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

527 lines
21 KiB
Solidity

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
/**
* @title AereMailboxV3 + AereIGPV3
* @notice Fix for the quoteDispatch gap in AERE's Hyperlane core on chain 2800.
*
* ROOT CAUSE this file fixes (diagnosed on-chain 2026-07-10):
* 1. The deployed AereMessenger (0xe54c2329f0786CFE3420c566B646148D25477325)
* does NOT implement quoteDispatch(uint32,bytes32,bytes) at all. Selector
* 0x9c42bd18 is absent from its runtime bytecode, so every eth_call to
* quoteDispatch reverts with empty data. It is not upgradeable, so no
* configuration can add the function.
* 2. The deployed AereIGP (0x61B48615F490A23945988c92835eF35fdD86E837) has
* zero destination gas configs (gasOverhead=0, gasPriceQuoteAere=0 for
* every domain), so even its own quoteGasPayment(uint32) returns 0.
* 3. Both are owned by the Foundation multisig, so neither could be
* reconfigured by the deployer anyway.
*
* WHAT THIS IS:
* - AereMailboxV3: a Hyperlane v3 wire-format mailbox. Messages are packed
* exactly like Hyperlane v3 (version || nonce || origin || sender ||
* destination || recipient || body), messageId = keccak256(message).
* quoteDispatch(uint32,bytes32,bytes) works and returns the fee that
* dispatch requires as msg.value. dispatch REQUIRES the quoted fee and
* forwards it to the post-dispatch hook (the IGP), emitting the same
* Dispatch / DispatchId events as Hyperlane v3.
* - Inbound verification keeps the AereMessenger operational model: an
* owner-managed ECDSA validator set with a threshold, signature bytes are
* passed as the `metadata` argument of process(bytes,bytes). This is the
* same trust model as the live AereMessenger relayer, so no infra change.
* - AereIGPV3: a Hyperlane v3 post-dispatch hook (hookType 4,
* INTERCHAIN_GAS_PAYMASTER). quoteDispatch(metadata, message) parses the
* destination domain from the packed message and prices it as
* (gasOverhead + gasLimit) * gasPriceAereWei. gasLimit comes from
* StandardHookMetadata if provided, else DEFAULT_GAS_USAGE (50k, same
* default as Hyperlane's IGP).
*
* NO TOKEN of any kind is introduced. Fees are native AERE.
*/
// ─────────────────────────── Hyperlane v3 message lib ───────────────────────────
library HypMessageV3 {
uint8 internal constant VERSION = 3;
// Packed layout offsets (bytes):
// version [0,1)
// nonce [1,5)
// origin [5,9)
// sender [9,41)
// destination[41,45)
// recipient [45,77)
// body [77,..)
uint256 internal constant BODY_OFFSET = 77;
function format(
uint32 _nonce,
uint32 _origin,
bytes32 _sender,
uint32 _destination,
bytes32 _recipient,
bytes calldata _body
) internal pure returns (bytes memory) {
return abi.encodePacked(VERSION, _nonce, _origin, _sender, _destination, _recipient, _body);
}
function id(bytes memory _message) internal pure returns (bytes32) {
return keccak256(_message);
}
function version(bytes calldata _message) internal pure returns (uint8) {
return uint8(bytes1(_message[0:1]));
}
function nonce(bytes calldata _message) internal pure returns (uint32) {
return uint32(bytes4(_message[1:5]));
}
function origin(bytes calldata _message) internal pure returns (uint32) {
return uint32(bytes4(_message[5:9]));
}
function sender(bytes calldata _message) internal pure returns (bytes32) {
return bytes32(_message[9:41]);
}
function destination(bytes calldata _message) internal pure returns (uint32) {
return uint32(bytes4(_message[41:45]));
}
function recipient(bytes calldata _message) internal pure returns (bytes32) {
return bytes32(_message[45:77]);
}
function recipientAddress(bytes calldata _message) internal pure returns (address) {
return address(uint160(uint256(recipient(_message))));
}
function body(bytes calldata _message) internal pure returns (bytes calldata) {
return _message[BODY_OFFSET:];
}
}
// ─────────────────────────── Interfaces ───────────────────────────
interface IPostDispatchHookV3 {
function hookType() external view returns (uint8);
function supportsMetadata(bytes calldata metadata) external view returns (bool);
function postDispatch(bytes calldata metadata, bytes calldata message) external payable;
function quoteDispatch(bytes calldata metadata, bytes calldata message) external view returns (uint256);
}
interface IMessageRecipientV3 {
function handle(uint32 origin, bytes32 sender, bytes calldata body) external payable;
}
// ─────────────────────────── AereIGPV3 (post-dispatch hook) ───────────────────────────
/**
* @notice Interchain Gas Paymaster, Hyperlane v3 hook flavour.
* fee(domain, gasLimit) = (gasOverhead[domain] + gasLimit) * gasPriceAereWei[domain]
* gasPriceAereWei is the owner-set, AERE-denominated price of ONE unit of
* destination-chain gas (destination gas price x destination-token/AERE rate,
* folded into a single number until a cross-chain price oracle is wired).
*/
contract AereIGPV3 is Ownable, IPostDispatchHookV3 {
// Hyperlane v3 IPostDispatchHook.Types.INTERCHAIN_GAS_PAYMASTER
uint8 public constant HOOK_TYPE = 4;
// Same default as Hyperlane's InterchainGasPaymaster when metadata omits gasLimit.
uint256 public constant DEFAULT_GAS_USAGE = 50_000;
struct DomainGasConfig {
uint96 gasOverhead; // destination-side fixed gas overhead (ISM verify + mailbox process)
uint256 gasPriceAereWei; // wei of AERE per unit of destination gas
}
struct GasParam {
uint32 remoteDomain;
DomainGasConfig config;
}
mapping(uint32 => DomainGasConfig) public destinationGasConfigs;
/// Fees accumulate here and are claimable to the beneficiary (relayer funding wallet).
address public beneficiary;
event GasPayment(bytes32 indexed messageId, uint32 indexed destinationDomain, uint256 gasAmount, uint256 payment);
event DestinationGasConfigSet(uint32 indexed remoteDomain, uint96 gasOverhead, uint256 gasPriceAereWei);
event BeneficiarySet(address indexed beneficiary);
event Claimed(address indexed beneficiary, uint256 amount);
constructor(address _beneficiary) {
require(_beneficiary != address(0), "AereIGPV3: zero beneficiary");
beneficiary = _beneficiary;
}
// ── owner config ──
function setDestinationGasConfigs(GasParam[] calldata params) external onlyOwner {
for (uint256 i = 0; i < params.length; i++) {
require(params[i].config.gasPriceAereWei > 0, "AereIGPV3: zero gas price");
destinationGasConfigs[params[i].remoteDomain] = params[i].config;
emit DestinationGasConfigSet(
params[i].remoteDomain,
params[i].config.gasOverhead,
params[i].config.gasPriceAereWei
);
}
}
function setBeneficiary(address _beneficiary) external onlyOwner {
require(_beneficiary != address(0), "AereIGPV3: zero beneficiary");
beneficiary = _beneficiary;
emit BeneficiarySet(_beneficiary);
}
// ── quoting ──
function quoteGasPayment(uint32 destinationDomain, uint256 gasLimit) public view returns (uint256) {
DomainGasConfig memory c = destinationGasConfigs[destinationDomain];
require(c.gasPriceAereWei > 0, "AereIGPV3: domain not configured");
return (uint256(c.gasOverhead) + gasLimit) * c.gasPriceAereWei;
}
// ── IPostDispatchHookV3 ──
function hookType() external pure returns (uint8) {
return HOOK_TYPE;
}
/// Metadata is either empty or StandardHookMetadata:
/// variant(2) || msgValue(32) || gasLimit(32) || refundAddress(20)
function supportsMetadata(bytes calldata metadata) external pure returns (bool) {
return metadata.length == 0 || (metadata.length >= 66 && uint16(bytes2(metadata[0:2])) == 1);
}
function _gasLimit(bytes calldata metadata) internal pure returns (uint256) {
if (metadata.length >= 66 && uint16(bytes2(metadata[0:2])) == 1) {
return uint256(bytes32(metadata[34:66]));
}
return DEFAULT_GAS_USAGE;
}
function _refundAddress(bytes calldata metadata) internal pure returns (address) {
if (metadata.length >= 86 && uint16(bytes2(metadata[0:2])) == 1) {
return address(bytes20(metadata[66:86]));
}
return address(0);
}
function quoteDispatch(bytes calldata metadata, bytes calldata message) public view returns (uint256) {
return quoteGasPayment(HypMessageV3.destination(message), _gasLimit(metadata));
}
function postDispatch(bytes calldata metadata, bytes calldata message) external payable {
uint256 gasLimit = _gasLimit(metadata);
uint32 destination = HypMessageV3.destination(message);
uint256 required = quoteGasPayment(destination, gasLimit);
require(msg.value >= required, "AereIGPV3: insufficient gas payment");
uint256 overpayment = msg.value - required;
if (overpayment > 0) {
address refundTo = _refundAddress(metadata);
if (refundTo != address(0)) {
(bool ok, ) = refundTo.call{ value: overpayment }("");
require(ok, "AereIGPV3: refund failed");
}
// If no refund address was given, the overpayment stays claimable
// by the beneficiary (identical to Hyperlane IGP behaviour when
// refunding is not possible).
}
emit GasPayment(keccak256(message), destination, gasLimit, required);
}
// ── payout ──
function claim() external {
uint256 bal = address(this).balance;
require(bal > 0, "AereIGPV3: nothing to claim");
address to = beneficiary;
(bool ok, ) = to.call{ value: bal }("");
require(ok, "AereIGPV3: payout failed");
emit Claimed(to, bal);
}
receive() external payable {}
}
// ─────────────────────────── AereMailboxV3 ───────────────────────────
contract AereMailboxV3 is Ownable, ReentrancyGuard {
using HypMessageV3 for bytes;
uint8 public constant VERSION = HypMessageV3.VERSION;
uint32 public immutable localDomain;
/// Outbound nonce, incremented per dispatch (Hyperlane v3 semantics).
uint32 public nonce;
/// Latest dispatched message id (Hyperlane v3 parity).
bytes32 public latestDispatchedId;
/// Post-dispatch hooks. requiredHook is charged first, then defaultHook.
/// Either may be zero (skipped). For AERE both quoting and payment go to
/// the AereIGPV3 as defaultHook; requiredHook is left zero at deploy.
IPostDispatchHookV3 public defaultHook;
IPostDispatchHookV3 public requiredHook;
/// Inbound verification: owner-managed ECDSA validator set (same
/// operational model as the live AereMessenger, so the existing relayer
/// design carries over; Foundation rotates the set).
address[] public validators;
mapping(address => bool) public isValidator;
uint8 public threshold = 1;
/// messageId => delivered (replay protection).
mapping(bytes32 => bool) public deliveredMessages;
bool public paused;
// Hyperlane v3 events, byte-for-byte compatible with indexers.
event Dispatch(address indexed sender, uint32 indexed destination, bytes32 indexed recipient, bytes message);
event DispatchId(bytes32 indexed messageId);
event Process(uint32 indexed origin, bytes32 indexed sender, address indexed recipient);
event ProcessId(bytes32 indexed messageId);
event ValidatorAdded(address indexed validator);
event ValidatorRemoved(address indexed validator);
event ThresholdChanged(uint8 newThreshold);
event DefaultHookSet(address indexed hook);
event RequiredHookSet(address indexed hook);
event PausedSet(bool paused);
constructor(uint32 _localDomain) {
localDomain = _localDomain;
}
modifier whenNotPaused() {
require(!paused, "AereMailboxV3: paused");
_;
}
// ───────────────────── Outbound ─────────────────────
function dispatch(
uint32 destinationDomain,
bytes32 recipientAddress,
bytes calldata messageBody
) external payable returns (bytes32) {
return _dispatch(destinationDomain, recipientAddress, messageBody, "");
}
/// Hyperlane v3 overload: dispatch with StandardHookMetadata (custom gas limit / refund address).
function dispatch(
uint32 destinationDomain,
bytes32 recipientAddress,
bytes calldata messageBody,
bytes calldata hookMetadata
) external payable returns (bytes32) {
return _dispatch(destinationDomain, recipientAddress, messageBody, hookMetadata);
}
function _dispatch(
uint32 destinationDomain,
bytes32 recipientAddress,
bytes calldata messageBody,
bytes memory hookMetadata
) internal whenNotPaused nonReentrant returns (bytes32) {
uint32 n = nonce;
bytes memory message = HypMessageV3.format(
n,
localDomain,
bytes32(uint256(uint160(msg.sender))),
destinationDomain,
recipientAddress,
messageBody
);
bytes32 messageId = HypMessageV3.id(message);
nonce = n + 1;
latestDispatchedId = messageId;
emit Dispatch(msg.sender, destinationDomain, recipientAddress, message);
emit DispatchId(messageId);
// Charge hooks: required first, then default.
uint256 remaining = msg.value;
remaining = _payHook(requiredHook, hookMetadata, message, remaining);
remaining = _payHook(defaultHook, hookMetadata, message, remaining);
// Refund anything the hooks did not require.
if (remaining > 0) {
(bool ok, ) = msg.sender.call{ value: remaining }("");
require(ok, "AereMailboxV3: refund failed");
}
return messageId;
}
function _payHook(
IPostDispatchHookV3 hook,
bytes memory metadata,
bytes memory message,
uint256 available
) internal returns (uint256) {
if (address(hook) == address(0)) return available;
uint256 fee = _quoteHook(hook, metadata, message);
require(available >= fee, "AereMailboxV3: insufficient hook payment");
hook.postDispatch{ value: fee }(metadata, message);
return available - fee;
}
function _quoteHook(
IPostDispatchHookV3 hook,
bytes memory metadata,
bytes memory message
) internal view returns (uint256) {
if (address(hook) == address(0)) return 0;
return hook.quoteDispatch(metadata, message);
}
// ───────────────────── Quoting (THE FIX) ─────────────────────
/// @notice Hyperlane v3 quoteDispatch — the function the old AereMessenger
/// never had. Returns the exact msg.value dispatch() requires.
function quoteDispatch(
uint32 destinationDomain,
bytes32 recipientAddress,
bytes calldata messageBody
) external view returns (uint256) {
return _quote(destinationDomain, recipientAddress, messageBody, "");
}
/// v3 overload with hook metadata.
function quoteDispatch(
uint32 destinationDomain,
bytes32 recipientAddress,
bytes calldata messageBody,
bytes calldata hookMetadata
) external view returns (uint256) {
return _quote(destinationDomain, recipientAddress, messageBody, hookMetadata);
}
function _quote(
uint32 destinationDomain,
bytes32 recipientAddress,
bytes calldata messageBody,
bytes memory hookMetadata
) internal view returns (uint256) {
bytes memory message = HypMessageV3.format(
nonce,
localDomain,
bytes32(uint256(uint160(msg.sender))),
destinationDomain,
recipientAddress,
messageBody
);
return
_quoteHook(requiredHook, hookMetadata, message) +
_quoteHook(defaultHook, hookMetadata, message);
}
// ───────────────────── Inbound ─────────────────────
/// @notice Hyperlane v3 process signature. `metadata` carries concatenated
/// 65-byte ECDSA validator signatures over the eth-signed messageId.
function process(bytes calldata metadata, bytes calldata message) external nonReentrant whenNotPaused {
require(message.version() == VERSION, "AereMailboxV3: bad version");
require(message.destination() == localDomain, "AereMailboxV3: wrong destination");
bytes32 messageId = keccak256(message);
require(!deliveredMessages[messageId], "AereMailboxV3: already delivered");
bytes32 digest = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", messageId));
require(metadata.length % 65 == 0, "AereMailboxV3: bad sig length");
uint256 sigCount = metadata.length / 65;
require(sigCount >= threshold, "AereMailboxV3: below threshold");
address[] memory seen = new address[](sigCount);
for (uint256 i = 0; i < sigCount; i++) {
bytes32 r;
bytes32 s;
uint8 v;
assembly {
let off := add(metadata.offset, mul(i, 65))
r := calldataload(off)
s := calldataload(add(off, 32))
v := byte(0, calldataload(add(off, 64)))
}
address signer = ecrecover(digest, v, r, s);
require(signer != address(0), "AereMailboxV3: bad signature");
require(isValidator[signer], "AereMailboxV3: not a validator");
for (uint256 j = 0; j < i; j++) {
require(seen[j] != signer, "AereMailboxV3: duplicate signer");
}
seen[i] = signer;
}
deliveredMessages[messageId] = true;
IMessageRecipientV3(message.recipientAddress()).handle(
message.origin(),
message.sender(),
message.body()
);
emit Process(message.origin(), message.sender(), message.recipientAddress());
emit ProcessId(messageId);
}
function delivered(bytes32 messageId) external view returns (bool) {
return deliveredMessages[messageId];
}
// ───────────────────── Admin ─────────────────────
function setDefaultHook(address _hook) external onlyOwner {
defaultHook = IPostDispatchHookV3(_hook);
emit DefaultHookSet(_hook);
}
function setRequiredHook(address _hook) external onlyOwner {
requiredHook = IPostDispatchHookV3(_hook);
emit RequiredHookSet(_hook);
}
function addValidator(address v) external onlyOwner {
require(v != address(0), "AereMailboxV3: zero validator");
require(!isValidator[v], "AereMailboxV3: already added");
isValidator[v] = true;
validators.push(v);
emit ValidatorAdded(v);
}
function removeValidator(address v) external onlyOwner {
require(isValidator[v], "AereMailboxV3: not found");
isValidator[v] = false;
for (uint256 i = 0; i < validators.length; i++) {
if (validators[i] == v) {
validators[i] = validators[validators.length - 1];
validators.pop();
break;
}
}
require(threshold <= validators.length, "AereMailboxV3: threshold too high after removal");
emit ValidatorRemoved(v);
}
function setThreshold(uint8 _threshold) external onlyOwner {
require(_threshold > 0 && _threshold <= validators.length, "AereMailboxV3: bad threshold");
threshold = _threshold;
emit ThresholdChanged(_threshold);
}
function setPaused(bool _paused) external onlyOwner {
paused = _paused;
emit PausedSet(_paused);
}
function validatorCount() external view returns (uint256) {
return validators.length;
}
}