// shutter-mempool-v3.test.js // // Audit-fix suite for AereShutterMempoolV3: reveal-timeout skip that restores // liveness. Follows the repo's V-reproduces / V-corrects pattern — every new // behaviour is shown STALLING on the live V2 design and RECOVERING on V3, using // the SAME real 3-of-5 threshold-BLS committee produced by scripts/shutter-crypto.js // (real ciphertexts, real shares, real Lagrange DK), all pairings verified // on-chain via the local prague EIP-2537 pairing precompile (0x0f), the same // precompile probed live on AERE chain 2800. // // npx hardhat test test/shutter-mempool-v3.test.js --config hardhat.config.shutter-v3.js const { expect } = require("chai"); const { ethers, network } = require("hardhat"); const { anyValue } = require("@nomicfoundation/hardhat-chai-matchers/withArgs"); const crypto = require("crypto"); const C = require("../scripts/shutter-crypto"); const T = 3, N = 5; const REVEAL_WINDOW = 3600; // seconds; short enough to fast-forward in tests function rand32() { return "0x" + crypto.randomBytes(32).toString("hex"); } function toHex(buf) { return "0x" + buf.toString("hex"); } async function increaseTime(seconds) { await network.provider.send("evm_increaseTime", [seconds]); await network.provider.send("evm_mine"); } // Build the encrypted envelopes + sequencer ordering (execution order == the // given permutation of submission order; default identity). function buildEnvelopes(com, epochId, plaintexts, order) { order = order || plaintexts.map((_, i) => i); const env = plaintexts.map((pt) => { const e = C.encrypt(com, epochId, pt); const opening = rand32(); const plaintextHex = toHex(pt); const commitment = C.commitmentOf(plaintextHex, opening); return { ciphertext: e.ciphertext, opening, plaintextHex, commitment, pt }; }); const leaves = order.map((srcIdx, pos) => C.leafOf(epochId, pos, env[srcIdx].commitment)); const { root, proofs } = C.buildMerkle(leaves); return { env, order, root, proofs }; } // Deploy + register a fresh committee against a given contract name. // V2 ctor = (coordinator, g2Gen); V3 ctor = (coordinator, g2Gen, revealWindow). async function deployAndRegister(name) { const [coordinator] = await ethers.getSigners(); const com = C.setupCommittee(T, N); const F = await ethers.getContractFactory(name); const args = name === "AereShutterMempoolV3" ? [coordinator.address, com.g2Gen, REVEAL_WINDOW] : [coordinator.address, com.g2Gen]; const mempool = await F.deploy(...args); await mempool.waitForDeployment(); const keyperAddrs = Array.from({ length: N }, () => ethers.Wallet.createRandom().address); await (await mempool.registerKeypers(keyperAddrs, com.pubkeys, T)).wait(); return { mempool, com, coordinator, keyperAddrs }; } function shareFor(com, ep, keyperIndex) { return C.makeShare(com.shares[keyperIndex].secret, ep.point); } // Drive an epoch through: start -> submit -> commit ordering -> 3 shares -> finalize. // Returns the built envelopes/order/proofs so the caller can reveal/skip. async function runToFinalized(mempool, com, epochId, plaintexts, order) { const ep = C.epochIdentity(epochId); const built = buildEnvelopes(com, epochId, plaintexts, order); await (await mempool.startEpoch(epochId, com.PK, ep.H1, ep.H1neg)).wait(); for (const e of built.env) await (await mempool.submitEncrypted(epochId, e.ciphertext, e.commitment)).wait(); await (await mempool.commitOrdering(epochId, built.root)).wait(); for (const k of [0, 1, 2]) await (await mempool.submitDecryptionShare(epochId, k, shareFor(com, ep, k))).wait(); await (await mempool.finalizeEpoch(epochId, C.reconstructDK(ep.point, com, [1, 2, 3]).DK)).wait(); return { ep, built }; } describe("AereShutterMempoolV3 (reveal-timeout skip restores liveness)", function () { let v3, v2; before(async function () { this.timeout(180000); // EIP-2537 BLS12-381 precompiles (incl. pairing 0x0f) activate in PRAGUE. // Run under hardhat.config.shutter-v3.js (prague). Skip cleanly if absent. const id = "0x" + [C.g1Hex(C.G1.BASE), C.g2Hex(C.G2.BASE), C.g1Hex(C.G1.BASE.negate()), C.g2Hex(C.G2.BASE)].map((h) => h.slice(2)).join(""); let ok = false; try { ok = BigInt(await ethers.provider.call({ to: "0x000000000000000000000000000000000000000f", data: id })) === 1n; } catch (_) {} if (!ok) { console.warn(" [skip] local node has no EIP-2537 pairing precompile (0x0f). Run with --config hardhat.config.shutter-v3.js (prague)."); this.skip(); } v3 = await deployAndRegister("AereShutterMempoolV3"); v2 = await deployAndRegister("AereShutterMempoolV2"); }); it("registers a 3-of-5 committee and exposes REVEAL_WINDOW", async () => { expect(await v3.mempool.committeeSet()).to.equal(true); expect(await v3.mempool.threshold()).to.equal(T); expect(await v3.mempool.keyperCount()).to.equal(N); expect(await v3.mempool.REVEAL_WINDOW()).to.equal(REVEAL_WINDOW); }); it("constructor rejects a zero reveal window", async () => { const [coordinator] = await ethers.getSigners(); const com = C.setupCommittee(T, N); const F = await ethers.getContractFactory("AereShutterMempoolV3"); await expect(F.deploy(coordinator.address, com.g2Gen, 0)) .to.be.revertedWithCustomError(F, "BadArgs"); }); it("happy path UNCHANGED: encrypt -> order -> 3 shares -> finalize -> reveal both in order (no skip)", async function () { this.timeout(120000); const { mempool, com } = v3; const epochId = 300; const plaintexts = [ Buffer.from("transfer(0xAbCd, 100 AERE) nonce=7 || opening-embedded", "utf8"), Buffer.from("swap 5 USDC.e -> AERE minOut=4.98 || opening-embedded", "utf8"), ]; const { built } = await runToFinalized(mempool, com, epochId, plaintexts); expect(await mempool.isFinalized(epochId)).to.equal(true); // deadline was stamped at finalize const info0 = await mempool.getEpoch(epochId); expect(info0.revealDeadline).to.be.greaterThan(0n); for (let pos = 0; pos < built.order.length; pos++) { const e = built.env[built.order[pos]]; await expect(mempool.revealAndExecute(epochId, pos, e.plaintextHex, e.opening, built.proofs[pos])) .to.emit(mempool, "Executed"); } const after = await mempool.getEpoch(epochId); expect(after.nextExecIndex).to.equal(2); }); it("V2 STALL (reproduce): an unrevealed position 0 permanently blocks position 1 (no skip exists)", async function () { this.timeout(120000); const { mempool, com } = v2; // the live V2 design const epochId = 301; const plaintexts = [Buffer.from("stuck-position-0", "utf8"), Buffer.from("victim-position-1", "utf8")]; const { built } = await runToFinalized(mempool, com, epochId, plaintexts); // position 0 is deliberately never revealed. position 1 cannot execute: await expect(mempool.revealAndExecute(epochId, 1, built.env[1].plaintextHex, built.env[1].opening, built.proofs[1])) .to.be.revertedWithCustomError(mempool, "OutOfOrder"); // there is NO skip path in V2 (function does not exist on the ABI) -> permanent stall expect(typeof mempool.skipUnrevealed).to.equal("undefined"); const info = await mempool.getEpoch(epochId); expect(info.nextExecIndex).to.equal(0); // cursor frozen at the stuck head forever }); it("V3 FIX: unrevealed position 0 -> skipUnrevealed after deadline advances -> position 1 executes", async function () { this.timeout(120000); const { mempool, com } = v3; const epochId = 302; const plaintexts = [Buffer.from("stuck-position-0", "utf8"), Buffer.from("victim-position-1", "utf8")]; const { built } = await runToFinalized(mempool, com, epochId, plaintexts); // position 1 still blocked while position 0 is the (unrevealed) head await expect(mempool.revealAndExecute(epochId, 1, built.env[1].plaintextHex, built.env[1].opening, built.proofs[1])) .to.be.revertedWithCustomError(mempool, "OutOfOrder"); // BEFORE the deadline the skip reverts await expect(mempool.skipUnrevealed(epochId, 0)) .to.be.revertedWithCustomError(mempool, "SkipTooEarly"); // fast-forward past the reveal window, then skip the stuck head (permissionless) await increaseTime(REVEAL_WINDOW + 5); const caller = (await ethers.getSigners())[0].address; await expect(mempool.skipUnrevealed(epochId, 0)) .to.emit(mempool, "PositionSkipped").withArgs(epochId, 0, anyValue, caller); const mid = await mempool.getEpoch(epochId); expect(mid.nextExecIndex).to.equal(1); // cursor advanced past the skipped position expect(await mempool.skippedPosition(epochId, 0)).to.equal(true); // now position 1 executes -> liveness restored await expect(mempool.revealAndExecute(epochId, 1, built.env[1].plaintextHex, built.env[1].opening, built.proofs[1])) .to.emit(mempool, "Executed"); const after = await mempool.getEpoch(epochId); expect(after.nextExecIndex).to.equal(2); }); it("skipUnrevealed BEFORE the deadline reverts SkipTooEarly", async function () { this.timeout(120000); const { mempool, com } = v3; const epochId = 303; const plaintexts = [Buffer.from("head-A", "utf8"), Buffer.from("tail-B", "utf8")]; await runToFinalized(mempool, com, epochId, plaintexts); // no time advance: still inside the window await expect(mempool.skipUnrevealed(epochId, 0)) .to.be.revertedWithCustomError(mempool, "SkipTooEarly"); }); it("skipping a position that WAS revealed reverts (cursor already past it)", async function () { this.timeout(120000); const { mempool, com } = v3; const epochId = 304; const plaintexts = [Buffer.from("revealed-0", "utf8"), Buffer.from("later-1", "utf8")]; const { built } = await runToFinalized(mempool, com, epochId, plaintexts); // reveal position 0 legitimately -> cursor -> 1 await (await mempool.revealAndExecute(epochId, 0, built.env[0].plaintextHex, built.env[0].opening, built.proofs[0])).wait(); // even after the window closes, position 0 can NOT be skipped: it is behind the head await increaseTime(REVEAL_WINDOW + 5); await expect(mempool.skipUnrevealed(epochId, 0)) .to.be.revertedWithCustomError(mempool, "OutOfOrder"); expect(await mempool.skippedPosition(epochId, 0)).to.equal(false); }); it("skipUnrevealed reverts on a non-finalized epoch and past the last position", async function () { this.timeout(120000); const { mempool, com } = v3; const epochId = 305; // not finalized yet: only start + submit + order const ep = C.epochIdentity(epochId); const built = buildEnvelopes(com, epochId, [Buffer.from("only-0", "utf8")]); await (await mempool.startEpoch(epochId, com.PK, ep.H1, ep.H1neg)).wait(); await (await mempool.submitEncrypted(epochId, built.env[0].ciphertext, built.env[0].commitment)).wait(); await (await mempool.commitOrdering(epochId, built.root)).wait(); await increaseTime(REVEAL_WINDOW + 5); // Ordered (not Finalized) -> revealDeadline is 0 but the phase guard fires first await expect(mempool.skipUnrevealed(epochId, 0)) .to.be.revertedWithCustomError(mempool, "NotFinalized"); // finalize, then skip the single position, then a further skip past the end reverts NothingToSkip for (const k of [0, 1, 2]) await (await mempool.submitDecryptionShare(epochId, k, shareFor(com, ep, k))).wait(); await (await mempool.finalizeEpoch(epochId, C.reconstructDK(ep.point, com, [1, 2, 3]).DK)).wait(); await increaseTime(REVEAL_WINDOW + 5); await (await mempool.skipUnrevealed(epochId, 0)).wait(); // envelopeCount==1 -> index 0 skippable await expect(mempool.skipUnrevealed(epochId, 1)) .to.be.revertedWithCustomError(mempool, "NothingToSkip"); // 1 >= envelopeCount(1) }); it("no early decrypt: a decryption share before the ordering is committed still reverts", async function () { this.timeout(120000); const { mempool, com } = v3; const epochId = 306; const ep = C.epochIdentity(epochId); await (await mempool.startEpoch(epochId, com.PK, ep.H1, ep.H1neg)).wait(); await (await mempool.submitEncrypted(epochId, C.encrypt(com, epochId, Buffer.from("x")).ciphertext, C.commitmentOf("0x1234", rand32()))).wait(); // ordering NOT yet committed -> share must revert (prevents early-decrypt MEV) await expect(mempool.submitDecryptionShare(epochId, 0, shareFor(com, ep, 0))) .to.be.revertedWithCustomError(mempool, "OrderingNotCommitted"); }); it("out-of-order reveal still rejected (ordering guarantee preserved)", async function () { this.timeout(120000); const { mempool, com } = v3; const epochId = 307; const plaintexts = [Buffer.from("ord-0", "utf8"), Buffer.from("ord-1", "utf8")]; const { built } = await runToFinalized(mempool, com, epochId, plaintexts); await expect(mempool.revealAndExecute(epochId, 1, built.env[1].plaintextHex, built.env[1].opening, built.proofs[1])) .to.be.revertedWithCustomError(mempool, "OutOfOrder"); await (await mempool.revealAndExecute(epochId, 0, built.env[0].plaintextHex, built.env[0].opening, built.proofs[0])).wait(); await expect(mempool.revealAndExecute(epochId, 1, built.env[1].plaintextHex, built.env[1].opening, built.proofs[1])) .to.emit(mempool, "Executed"); }); });