aere-contracts/contracts/bridge/AereMailboxV3.sol
Aere Network acac2f00a6
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 unpublished line of work joins the sanitized public line
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.
2026-08-15 13:59:30 +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 account (a single-key EOA today, not a 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;
}
}