aere-contracts/test/shutter-mempool-v3.test.js
Aere Network acac2f00a6
Some checks failed
contracts-ci / Install (lockfile) → compile → full test suite (push) Has been cancelled
contracts-ci / Ethereum interop (EIP-2537 BLS, prague hardfork) (push) Has been cancelled
contracts-ci / PQC known-answer tests (NIST vectors) (push) Has been cancelled
contracts-ci / Coverage (scoped, with artifacts) (push) Has been cancelled
The unpublished line of work joins the sanitized public line
The published line and the local line had no common ancestor: the public one
carried the redaction pass, the local one carried three weeks of corrections
that never shipped. This commit ports the local work onto the public line,
keeps every public redaction, and extends the same discretion to seven client
mentions that were still named in published comments.

Carried: LICENSE year and LICENSING.md; the measured burn figures replacing
the deflation claim (the vault holds ~0.137 AERE of 2.8 billion, and burn is
a share of validator coinbase revenue, which is zero today); 'audited' removed
from next to Bouncy Castle; citation paths rewritten to published form with
CITATIONS-UNRESOLVED.md remeasured 2026-08-11; VERIFY-POLICY.md; slashing and
ownership comments brought down to what the code does; the AerePyth repair;
the shutter test helper the tests cite; runnable package.json entries; the CI
file split into a GitHub/Gitea twin pair with a real measured test-run status;
and the .gitignore hardening written after a compiled artifact leaked a local
path in a sibling repository. A false '2-of-3 multisig' description of the
owner account is corrected to what the chain measures: an externally owned
account. The self-audit findings catalog stays unpublished pending an explicit
decision.
2026-08-15 13:59:30 +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 test/helpers/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("./helpers/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");
});
});