aere-contracts/test/shutter-mempool-v3.test.js
Aere Network a13a649b77
Some checks are pending
contracts-ci / Install (lockfile) → compile → full test suite (push) Waiting to run
contracts-ci / PQC known-answer tests (NIST vectors) (push) Waiting to run
contracts-ci / Coverage (scoped, with artifacts) (push) Waiting to run
Initial public release
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.
2026-07-20 01:02:37 +03:00

260 lines
13 KiB
JavaScript

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