// 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; } }