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.
527 lines
21 KiB
Solidity
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;
|
|
}
|
|
}
|