// AereMailboxV3 + AereIGPV3 — fix for the quoteDispatch gap in AERE's // Hyperlane core (chain 2800). // // Diagnosed root cause (on-chain, 2026-07-10): // - Live AereMessenger 0xe54c...7325 has NO quoteDispatch selector at all // (0x9c42bd18 absent from bytecode) -> empty revert on every call. // - Live AereIGP 0x61B4...6837 has zero destination gas configs -> quote 0. // - Both are Foundation-owned, so no deployer-side config fix is possible. // // This suite proves the V3 replacement: // quoteDispatch returns a sane nonzero quote, dispatch enforces + forwards // the fee, Dispatch/DispatchId/GasPayment events fire, inbound process // verifies validator signatures over the Hyperlane v3 packed message. // // Run: npx hardhat test test/hyperlane-mailbox-v3.test.js const { expect } = require("chai"); const { ethers } = require("hardhat"); const AERE_DOMAIN = 2800; const ETH_DOMAIN = 1; const BASE_DOMAIN = 8453; const DEFAULT_GAS_USAGE = 50_000n; // Documented sane placeholder pricing (owner-tunable): // fee = (gasOverhead + gasLimit) * gasPriceAereWei const ETH_OVERHEAD = 150_000n; const ETH_PRICE = 30n * 10n ** 9n; // 30 gwei-AERE per unit of Ethereum gas const BASE_OVERHEAD = 100_000n; const BASE_PRICE = 2n * 10n ** 9n; function b32(addr) { return ethers.zeroPadValue(addr, 32); } // Hyperlane v3 packed message. function formatMessage(nonce, origin, sender, destination, recipient, body) { return ethers.solidityPacked( ["uint8", "uint32", "uint32", "bytes32", "uint32", "bytes32", "bytes"], [3, nonce, origin, sender, destination, recipient, body] ); } // StandardHookMetadata: variant(2) || msgValue(32) || gasLimit(32) || refundAddress(20) function hookMetadata(gasLimit, refundAddress) { return ethers.solidityPacked( ["uint16", "uint256", "uint256", "address"], [1, 0, gasLimit, refundAddress] ); } describe("AereMailboxV3 + AereIGPV3 — quoteDispatch fix", function () { let deployer, validator, alice, foundation; let mailbox, igp, recipient; beforeEach(async function () { [deployer, validator, alice, foundation] = await ethers.getSigners(); igp = await (await ethers.getContractFactory("AereIGPV3")).deploy(foundation.address); await igp.waitForDeployment(); mailbox = await (await ethers.getContractFactory("AereMailboxV3")).deploy(AERE_DOMAIN); await mailbox.waitForDeployment(); await (await mailbox.setDefaultHook(await igp.getAddress())).wait(); await (await mailbox.addValidator(validator.address)).wait(); await ( await igp.setDestinationGasConfigs([ { remoteDomain: ETH_DOMAIN, config: { gasOverhead: ETH_OVERHEAD, gasPriceAereWei: ETH_PRICE } }, { remoteDomain: BASE_DOMAIN, config: { gasOverhead: BASE_OVERHEAD, gasPriceAereWei: BASE_PRICE } }, ]) ).wait(); recipient = await (await ethers.getContractFactory("TestRecipientV3")).deploy(); await recipient.waitForDeployment(); }); describe("quoteDispatch (the missing function)", function () { it("returns a sane nonzero quote for a configured domain", async function () { const body = ethers.toUtf8Bytes("hello ethereum"); const quote = await mailbox["quoteDispatch(uint32,bytes32,bytes)"](ETH_DOMAIN, b32(alice.address), body); // (150k overhead + 50k default gas) * 30 gwei = 0.006 AERE expect(quote).to.equal((ETH_OVERHEAD + DEFAULT_GAS_USAGE) * ETH_PRICE); expect(quote).to.equal(ethers.parseEther("0.006")); }); it("prices different domains differently", async function () { const body = ethers.toUtf8Bytes("hello base"); const quote = await mailbox["quoteDispatch(uint32,bytes32,bytes)"](BASE_DOMAIN, b32(alice.address), body); expect(quote).to.equal((BASE_OVERHEAD + DEFAULT_GAS_USAGE) * BASE_PRICE); }); it("honours a custom gasLimit via StandardHookMetadata", async function () { const body = ethers.toUtf8Bytes("big message"); const md = hookMetadata(300_000n, ethers.ZeroAddress); const quote = await mailbox["quoteDispatch(uint32,bytes32,bytes,bytes)"]( ETH_DOMAIN, b32(alice.address), body, md ); expect(quote).to.equal((ETH_OVERHEAD + 300_000n) * ETH_PRICE); }); it("reverts with a CLEAR error (not empty data) for an unconfigured domain", async function () { const body = ethers.toUtf8Bytes("hello nowhere"); await expect( mailbox["quoteDispatch(uint32,bytes32,bytes)"](424242, b32(alice.address), body) ).to.be.revertedWith("AereIGPV3: domain not configured"); }); it("quotes zero when no hooks are set (fee-free mode)", async function () { const bare = await (await ethers.getContractFactory("AereMailboxV3")).deploy(AERE_DOMAIN); await bare.waitForDeployment(); const q = await bare["quoteDispatch(uint32,bytes32,bytes)"](ETH_DOMAIN, b32(alice.address), "0x"); expect(q).to.equal(0n); }); }); describe("dispatch pays the quote", function () { it("reverts if msg.value < quote", async function () { const body = ethers.toUtf8Bytes("underpaid"); const quote = await mailbox["quoteDispatch(uint32,bytes32,bytes)"](ETH_DOMAIN, b32(alice.address), body); await expect( mailbox.connect(alice)["dispatch(uint32,bytes32,bytes)"](ETH_DOMAIN, b32(alice.address), body, { value: quote - 1n, }) ).to.be.revertedWith("AereMailboxV3: insufficient hook payment"); }); it("dispatch at exactly the quote emits Dispatch + DispatchId + GasPayment and funds the IGP", async function () { const body = ethers.toUtf8Bytes("paid in full"); const quote = await mailbox["quoteDispatch(uint32,bytes32,bytes)"](ETH_DOMAIN, b32(alice.address), body); const expectedMessage = formatMessage( 0, AERE_DOMAIN, b32(alice.address), ETH_DOMAIN, b32(alice.address), body ); const expectedId = ethers.keccak256(expectedMessage); const tx = mailbox.connect(alice)["dispatch(uint32,bytes32,bytes)"]( ETH_DOMAIN, b32(alice.address), body, { value: quote } ); await expect(tx) .to.emit(mailbox, "Dispatch").withArgs(alice.address, ETH_DOMAIN, b32(alice.address), expectedMessage) .and.to.emit(mailbox, "DispatchId").withArgs(expectedId) .and.to.emit(igp, "GasPayment").withArgs(expectedId, ETH_DOMAIN, DEFAULT_GAS_USAGE, quote); expect(await ethers.provider.getBalance(await igp.getAddress())).to.equal(quote); expect(await mailbox.nonce()).to.equal(1); expect(await mailbox.latestDispatchedId()).to.equal(expectedId); }); it("refunds overpayment to the sender", async function () { const body = ethers.toUtf8Bytes("overpaid"); const quote = await mailbox["quoteDispatch(uint32,bytes32,bytes)"](ETH_DOMAIN, b32(alice.address), body); const balBefore = await ethers.provider.getBalance(alice.address); const tx = await mailbox.connect(alice)["dispatch(uint32,bytes32,bytes)"]( ETH_DOMAIN, b32(alice.address), body, { value: quote + ethers.parseEther("1") } ); const rc = await tx.wait(); const gasCost = rc.gasUsed * rc.gasPrice; // Alice net spend = quote + tx gas (the extra 1 AERE came back). const balAfter = await ethers.provider.getBalance(alice.address); expect(balBefore - balAfter).to.equal(quote + gasCost); // IGP kept only the quote. expect(await ethers.provider.getBalance(await igp.getAddress())).to.equal(quote); }); it("dispatch to an unconfigured domain reverts clearly", async function () { await expect( mailbox.connect(alice)["dispatch(uint32,bytes32,bytes)"](424242, b32(alice.address), "0x", { value: ethers.parseEther("1"), }) ).to.be.revertedWith("AereIGPV3: domain not configured"); }); }); describe("inbound process (Hyperlane v3 packed message + validator multisig)", function () { async function signedInbound(body) { const message = formatMessage( 7, ETH_DOMAIN, b32(alice.address), AERE_DOMAIN, b32(await recipient.getAddress()), body ); const messageId = ethers.keccak256(message); const sig = await validator.signMessage(ethers.getBytes(messageId)); return { message, messageId, sig }; } it("delivers a validator-signed message to the recipient", async function () { const body = ethers.toUtf8Bytes("inbound payload"); const { message, messageId, sig } = await signedInbound(body); await expect(mailbox.process(sig, message)) .to.emit(mailbox, "Process").withArgs(ETH_DOMAIN, b32(alice.address), await recipient.getAddress()) .and.to.emit(mailbox, "ProcessId").withArgs(messageId) .and.to.emit(recipient, "Handled"); expect(await recipient.lastOrigin()).to.equal(ETH_DOMAIN); expect(await recipient.lastSender()).to.equal(b32(alice.address)); expect(ethers.toUtf8String(await recipient.lastBody())).to.equal("inbound payload"); expect(await mailbox.delivered(messageId)).to.equal(true); }); it("rejects replay", async function () { const { message, sig } = await signedInbound(ethers.toUtf8Bytes("once only")); await (await mailbox.process(sig, message)).wait(); await expect(mailbox.process(sig, message)).to.be.revertedWith("AereMailboxV3: already delivered"); }); it("rejects a non-validator signature", async function () { const body = ethers.toUtf8Bytes("forged"); const message = formatMessage( 8, ETH_DOMAIN, b32(alice.address), AERE_DOMAIN, b32(await recipient.getAddress()), body ); const badSig = await alice.signMessage(ethers.getBytes(ethers.keccak256(message))); await expect(mailbox.process(badSig, message)).to.be.revertedWith("AereMailboxV3: not a validator"); }); it("rejects a message addressed to another domain", async function () { const body = ethers.toUtf8Bytes("misrouted"); const message = formatMessage(9, ETH_DOMAIN, b32(alice.address), 9999, b32(await recipient.getAddress()), body); const sig = await validator.signMessage(ethers.getBytes(ethers.keccak256(message))); await expect(mailbox.process(sig, message)).to.be.revertedWith("AereMailboxV3: wrong destination"); }); }); describe("IGP hook surface + payout", function () { it("reports Hyperlane hookType INTERCHAIN_GAS_PAYMASTER (4)", async function () { expect(await igp.hookType()).to.equal(4); expect(await igp.supportsMetadata("0x")).to.equal(true); expect(await igp.supportsMetadata(hookMetadata(1n, ethers.ZeroAddress))).to.equal(true); }); it("claim() pays the whole balance to the beneficiary", async function () { const body = ethers.toUtf8Bytes("fund the igp"); const quote = await mailbox["quoteDispatch(uint32,bytes32,bytes)"](ETH_DOMAIN, b32(alice.address), body); await ( await mailbox.connect(alice)["dispatch(uint32,bytes32,bytes)"](ETH_DOMAIN, b32(alice.address), body, { value: quote, }) ).wait(); const before = await ethers.provider.getBalance(foundation.address); await (await igp.connect(alice).claim()).wait(); // permissionless, pays beneficiary only const after = await ethers.provider.getBalance(foundation.address); expect(after - before).to.equal(quote); expect(await ethers.provider.getBalance(await igp.getAddress())).to.equal(0n); }); it("only the owner can set gas configs or beneficiary", async function () { await expect( igp.connect(alice).setDestinationGasConfigs([ { remoteDomain: 1, config: { gasOverhead: 1n, gasPriceAereWei: 1n } }, ]) ).to.be.revertedWith("Ownable: caller is not the owner"); await expect(igp.connect(alice).setBeneficiary(alice.address)).to.be.revertedWith( "Ownable: caller is not the owner" ); await expect(mailbox.connect(alice).setDefaultHook(alice.address)).to.be.revertedWith( "Ownable: caller is not the owner" ); }); it("rejects zero gas price configs", async function () { await expect( igp.setDestinationGasConfigs([{ remoteDomain: 1, config: { gasOverhead: 1n, gasPriceAereWei: 0n } }]) ).to.be.revertedWith("AereIGPV3: zero gas price"); }); }); });