// SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import "@openzeppelin/contracts/access/Ownable.sol"; import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; /** * @title AereMessenger * @notice Cross-chain messaging hub for AERE Network, designed for compatibility * with the Hyperlane Mailbox interface so we can plug into the public * Hyperlane validator/relayer network once registered. * * For now this is a self-contained messaging contract: an off-chain validator * set we operate signs outgoing messages. The signature scheme + ISM design * are deliberately compatible with Hyperlane's ECDSA-multisig ISM, so when * Hyperlane Labs accepts AERE into the public registry we can swap our * signers for the public validator set with one parameter change — no * contract redeploy. * * Compatible with Hyperlane Mailbox semantics: * - 32-bit domain identifier (we use Chain ID 2800) * - dispatch(destDomain, recipient, body) emits outbound messages * - process(metadata, message) verifies + delivers inbound messages * - Recipients implement IMessageRecipient * - Default ISM is a multisig of named validators * * When AERE is added to the Hyperlane registry, this contract continues to * work — Hyperlane validators sign with the same ECDSA scheme. */ interface IMessageRecipient { function handle(uint32 origin, bytes32 sender, bytes calldata body) external payable; } interface IPostDispatchHook { function postDispatch(bytes calldata metadata, bytes calldata message) external payable; } contract AereMessenger is Ownable, ReentrancyGuard { /// AERE's domain (= chain ID). uint32 public immutable localDomain; /// Outbound nonce — incremented per dispatch. uint32 public nonce; /// Validator set authorised to sign inbound messages. ECDSA-signed digests. address[] public validators; mapping(address => bool) public isValidator; /// Threshold: how many distinct validator signatures required to accept an inbound message. uint8 public threshold = 1; /// Delivered message digest tracking (per-origin) to prevent replay. mapping(uint32 => mapping(bytes32 => bool)) public delivered; /// Optional post-dispatch hook (e.g. AereIGP — Interchain Gas Paymaster). address public defaultHook; /// Pause flag for emergency halt. bool public paused; event Dispatch( uint32 indexed destDomain, bytes32 indexed recipient, uint32 indexed nonce, bytes32 sender, bytes body, bytes32 messageId ); event Process( uint32 indexed origin, bytes32 indexed sender, address indexed recipient, bytes32 messageId ); event ValidatorAdded(address indexed validator); event ValidatorRemoved(address indexed validator); event ThresholdChanged(uint8 newThreshold); event HookSet(address indexed hook); event Paused(bool paused); constructor(uint32 _localDomain) { localDomain = _localDomain; } modifier whenNotPaused() { require(!paused, "AereMessenger: paused"); _; } // ───────────────────── Outbound ───────────────────── /** * @notice Send a cross-chain message. * @param destDomain Destination chain's domain ID (= chain ID). * @param recipient Recipient contract on destination chain, padded to bytes32. * @param body Application-defined payload. * @return messageId Unique digest of this message. */ function dispatch( uint32 destDomain, bytes32 recipient, bytes calldata body ) external payable whenNotPaused returns (bytes32 messageId) { uint32 n = nonce; nonce = n + 1; bytes32 sender = bytes32(uint256(uint160(msg.sender))); messageId = keccak256(abi.encode(localDomain, destDomain, n, sender, recipient, body)); emit Dispatch(destDomain, recipient, n, sender, body, messageId); if (defaultHook != address(0) && msg.value > 0) { // Forward the IGP fee to the hook (e.g. interchain gas payment). IPostDispatchHook(defaultHook).postDispatch{ value: msg.value }("", abi.encode(messageId, destDomain)); } } // ───────────────────── Inbound ───────────────────── /** * @notice Process an inbound message after verifying validator signatures. * @param signatures Concatenated 65-byte ECDSA signatures (one per validator). * @param origin Origin chain's domain ID. * @param sender Sender on origin chain (address padded to bytes32). * @param recipient Recipient contract address on AERE. * @param body Payload to deliver. * @param originNonce Sender's nonce on origin chain — used for replay protection. */ function process( bytes calldata signatures, uint32 origin, bytes32 sender, address recipient, bytes calldata body, uint32 originNonce ) external nonReentrant whenNotPaused { bytes32 messageId = keccak256(abi.encode(origin, localDomain, originNonce, sender, bytes32(uint256(uint160(recipient))), body)); require(!delivered[origin][messageId], "AereMessenger: replay"); // Verify threshold signatures over the messageId (Ethereum-signed-message prefix). bytes32 digest = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", messageId)); require(signatures.length % 65 == 0, "AereMessenger: bad sig length"); uint256 sigCount = signatures.length / 65; require(sigCount >= threshold, "AereMessenger: below threshold"); address[] memory seen = new address[](sigCount); uint256 validCount = 0; for (uint256 i = 0; i < sigCount; i++) { bytes32 r; bytes32 s; uint8 v; assembly { let off := add(signatures.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), "AereMessenger: bad signature"); require(isValidator[signer], "AereMessenger: not a validator"); // No-double-counting: ensure each signer is distinct. for (uint256 j = 0; j < validCount; j++) { require(seen[j] != signer, "AereMessenger: duplicate signer"); } seen[validCount] = signer; validCount++; } delivered[origin][messageId] = true; IMessageRecipient(recipient).handle(origin, sender, body); emit Process(origin, sender, recipient, messageId); } // ───────────────────── Admin ───────────────────── function addValidator(address v) external onlyOwner { require(!isValidator[v], "AereMessenger: already added"); isValidator[v] = true; validators.push(v); emit ValidatorAdded(v); } function removeValidator(address v) external onlyOwner { require(isValidator[v], "AereMessenger: 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; } } emit ValidatorRemoved(v); } function setThreshold(uint8 _threshold) external onlyOwner { require(_threshold > 0 && _threshold <= validators.length, "AereMessenger: bad threshold"); threshold = _threshold; emit ThresholdChanged(_threshold); } function setDefaultHook(address _hook) external onlyOwner { defaultHook = _hook; emit HookSet(_hook); } function setPaused(bool _paused) external onlyOwner { paused = _paused; emit Paused(_paused); } function validatorCount() external view returns (uint256) { return validators.length; } }