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.
247 lines
12 KiB
JavaScript
247 lines
12 KiB
JavaScript
// shutter-mempool-v2.test.js
|
|
//
|
|
// Full PoC test for AereShutterMempoolV2: a REAL 3-of-5 threshold-BLS committee
|
|
// generated by scripts/shutter-crypto.js (real ciphertexts, real shares, real
|
|
// Lagrange reconstruction) with every pairing verified on-chain via the local
|
|
// prague EIP-2537 pairing precompile (0x0f), the same precompile probed live on
|
|
// AERE chain 2800.
|
|
const { expect } = require("chai");
|
|
const { ethers } = require("hardhat");
|
|
const crypto = require("crypto");
|
|
const C = require("../scripts/shutter-crypto");
|
|
|
|
const T = 3, N = 5;
|
|
|
|
function rand32() { return "0x" + crypto.randomBytes(32).toString("hex"); }
|
|
function toHex(buf) { return "0x" + buf.toString("hex"); }
|
|
|
|
// 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 };
|
|
});
|
|
// execution position `pos` reveals env[order[pos]]
|
|
const leaves = order.map((srcIdx, pos) => C.leafOf(epochId, pos, env[srcIdx].commitment));
|
|
const { root, proofs } = C.buildMerkle(leaves);
|
|
return { env, order, root, proofs };
|
|
}
|
|
|
|
async function deployAndRegister() {
|
|
const [coordinator] = await ethers.getSigners();
|
|
const com = C.setupCommittee(T, N);
|
|
const F = await ethers.getContractFactory("AereShutterMempoolV2");
|
|
const mempool = await F.deploy(coordinator.address, com.g2Gen);
|
|
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 };
|
|
}
|
|
|
|
// Shares for keyper indices (0-based). x-coord = index+1.
|
|
function shareFor(com, ep, keyperIndex) {
|
|
return C.makeShare(com.shares[keyperIndex].secret, ep.point);
|
|
}
|
|
|
|
describe("AereShutterMempoolV2 (real threshold-BLS anti-MEV mempool)", function () {
|
|
let ctx;
|
|
before(async function () {
|
|
this.timeout(120000);
|
|
// EIP-2537 BLS12-381 precompiles (incl. pairing 0x0f) activate in PRAGUE.
|
|
// The repo's default `hardhat` network is pinned to cancun (to preserve the
|
|
// Falcon-1024 gas-cap behaviour), so run this suite under a prague config:
|
|
// npx hardhat test test/shutter-mempool-v2.test.js --config hardhat.config.shutter-v2.js
|
|
// If the local node lacks the precompile, skip cleanly instead of failing.
|
|
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-v2.js (prague).");
|
|
this.skip();
|
|
}
|
|
ctx = await deployAndRegister();
|
|
});
|
|
|
|
it("registers a 3-of-5 committee and reports it", async () => {
|
|
expect(await ctx.mempool.committeeSet()).to.equal(true);
|
|
expect(await ctx.mempool.threshold()).to.equal(T);
|
|
expect(await ctx.mempool.keyperCount()).to.equal(N);
|
|
});
|
|
|
|
it("full happy path: encrypt -> order -> 3 valid shares -> finalize -> reveal both in order", async function () {
|
|
this.timeout(120000);
|
|
const { mempool, com } = ctx;
|
|
const epochId = 100;
|
|
const ep = C.epochIdentity(epochId);
|
|
const plaintexts = [
|
|
Buffer.from("transfer(0xAbCd, 100 AERE) nonce=7", "utf8"),
|
|
Buffer.from("swap 5 USDC.e -> AERE minOut=4.98", "utf8"),
|
|
];
|
|
const built = buildEnvelopes(com, epochId, plaintexts);
|
|
|
|
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();
|
|
|
|
let info = await mempool.getEpoch(epochId);
|
|
expect(info.phase).to.equal(1); // Open
|
|
expect(info.envelopeCount).to.equal(2);
|
|
|
|
// decryption share BEFORE ordering must revert (prevents early-decrypt MEV)
|
|
const s0 = shareFor(com, ep, 0);
|
|
await expect(mempool.submitDecryptionShare(epochId, 0, s0))
|
|
.to.be.revertedWithCustomError(mempool, "OrderingNotCommitted");
|
|
|
|
// sequencer commits the ordering
|
|
await (await mempool.commitOrdering(epochId, built.root)).wait();
|
|
info = await mempool.getEpoch(epochId);
|
|
expect(info.phase).to.equal(2); // Ordered
|
|
|
|
// 3 valid shares (keypers 0,1,2 -> x = 1,2,3), each pairing-verified on-chain
|
|
for (const k of [0, 1, 2]) {
|
|
await (await mempool.submitDecryptionShare(epochId, k, shareFor(com, ep, k))).wait();
|
|
}
|
|
info = await mempool.getEpoch(epochId);
|
|
expect(info.shareCount).to.equal(3);
|
|
|
|
// reconstruct DK off-chain via Lagrange from subset {1,2,3}; verify on-chain
|
|
const { DK, point: dkPoint } = C.reconstructDK(ep.point, com, [1, 2, 3]);
|
|
await (await mempool.finalizeEpoch(epochId, DK)).wait();
|
|
expect(await mempool.isFinalized(epochId)).to.equal(true);
|
|
|
|
// sanity: DK actually decrypts the real ciphertexts to the original plaintexts
|
|
for (let i = 0; i < plaintexts.length; i++) {
|
|
const dec = C.decrypt(dkPoint, built.env[i].ciphertext);
|
|
expect(dec.equals(plaintexts[i])).to.equal(true);
|
|
}
|
|
|
|
// reveal both, strictly in committed order
|
|
for (let pos = 0; pos < built.order.length; pos++) {
|
|
const src = built.order[pos];
|
|
const e = built.env[src];
|
|
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("rejects a share that is inconsistent with its VSS commitment (wrong share)", async () => {
|
|
const { mempool, com } = ctx;
|
|
const epochId = 101;
|
|
const ep = C.epochIdentity(epochId);
|
|
await (await mempool.startEpoch(epochId, com.PK, ep.H1, ep.H1neg)).wait();
|
|
const e = C.encrypt(com, epochId, Buffer.from("x", "utf8"));
|
|
const commitment = C.commitmentOf("0x1234", rand32());
|
|
await (await mempool.submitEncrypted(epochId, e.ciphertext, commitment)).wait();
|
|
await (await mempool.commitOrdering(epochId, ethers.keccak256("0xabcd"))).wait();
|
|
|
|
// keyper 0's real share, but submitted AS keyper index 1 -> pairing must fail
|
|
const wrong = shareFor(com, ep, 0);
|
|
await expect(mempool.submitDecryptionShare(epochId, 1, wrong))
|
|
.to.be.revertedWithCustomError(mempool, "BadShare");
|
|
|
|
// a truncated / malformed point is also rejected
|
|
await expect(mempool.submitDecryptionShare(epochId, 1, "0x1234"))
|
|
.to.be.revertedWithCustomError(mempool, "BadPointLength");
|
|
|
|
// the correct share for keyper 1 passes
|
|
await expect(mempool.submitDecryptionShare(epochId, 1, shareFor(com, ep, 1)))
|
|
.to.emit(mempool, "ShareSubmitted");
|
|
});
|
|
|
|
it("rejects a forged/incorrect DK reconstruction at finalize", async () => {
|
|
const { mempool, com } = ctx;
|
|
const epochId = 102;
|
|
const ep = C.epochIdentity(epochId);
|
|
await (await mempool.startEpoch(epochId, com.PK, ep.H1, ep.H1neg)).wait();
|
|
const e = C.encrypt(com, epochId, Buffer.from("y", "utf8"));
|
|
await (await mempool.submitEncrypted(epochId, e.ciphertext, C.commitmentOf("0x22", rand32()))).wait();
|
|
await (await mempool.commitOrdering(epochId, ethers.keccak256("0xbeef"))).wait();
|
|
for (const k of [0, 1, 2]) await (await mempool.submitDecryptionShare(epochId, k, shareFor(com, ep, k))).wait();
|
|
|
|
// a DK from a WRONG epoch's identity does not satisfy e(DK,g2)==e(H1,PK)
|
|
const wrongEp = C.epochIdentity(999);
|
|
const { DK: wrongDK } = C.reconstructDK(wrongEp.point, com, [1, 2, 3]);
|
|
await expect(mempool.finalizeEpoch(epochId, wrongDK))
|
|
.to.be.revertedWithCustomError(mempool, "BadReconstruction");
|
|
|
|
// the correct DK finalizes
|
|
const { DK } = C.reconstructDK(ep.point, com, [1, 2, 3]);
|
|
await expect(mempool.finalizeEpoch(epochId, DK)).to.emit(mempool, "EpochFinalized");
|
|
});
|
|
|
|
it("reverts finalize with fewer than t shares", async () => {
|
|
const { mempool, com } = ctx;
|
|
const epochId = 103;
|
|
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("z")).ciphertext, C.commitmentOf("0x33", rand32()))).wait();
|
|
await (await mempool.commitOrdering(epochId, ethers.keccak256("0xcafe"))).wait();
|
|
for (const k of [0, 1]) await (await mempool.submitDecryptionShare(epochId, k, shareFor(com, ep, k))).wait(); // only 2
|
|
const { DK } = C.reconstructDK(ep.point, com, [1, 2, 3]);
|
|
await expect(mempool.finalizeEpoch(epochId, DK)).to.be.revertedWithCustomError(mempool, "NotEnoughShares");
|
|
});
|
|
|
|
it("reverts revealAndExecute with a plaintext that does not open the commitment", async () => {
|
|
const { mempool, com } = ctx;
|
|
const epochId = 104;
|
|
const ep = C.epochIdentity(epochId);
|
|
const plaintexts = [Buffer.from("real-payload-A", "utf8"), Buffer.from("real-payload-B", "utf8")];
|
|
const built = buildEnvelopes(com, epochId, plaintexts);
|
|
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();
|
|
|
|
// tampered plaintext at position 0 with the real opening/proof -> commitment mismatch
|
|
const tampered = toHex(Buffer.from("EVIL-payload-A", "utf8"));
|
|
await expect(mempool.revealAndExecute(epochId, 0, tampered, built.env[0].opening, built.proofs[0]))
|
|
.to.be.revertedWithCustomError(mempool, "UnknownCommitment");
|
|
|
|
// correct reveal at position 0 works
|
|
await expect(mempool.revealAndExecute(epochId, 0, built.env[0].plaintextHex, built.env[0].opening, built.proofs[0]))
|
|
.to.emit(mempool, "Executed");
|
|
});
|
|
|
|
it("reverts revealAndExecute out of committed order", async () => {
|
|
const { mempool, com } = ctx;
|
|
const epochId = 105;
|
|
const ep = C.epochIdentity(epochId);
|
|
const plaintexts = [Buffer.from("order-0", "utf8"), Buffer.from("order-1", "utf8")];
|
|
const built = buildEnvelopes(com, epochId, plaintexts);
|
|
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();
|
|
|
|
// trying to execute position 1 before position 0 must revert
|
|
await expect(mempool.revealAndExecute(epochId, 1, built.env[1].plaintextHex, built.env[1].opening, built.proofs[1]))
|
|
.to.be.revertedWithCustomError(mempool, "OutOfOrder");
|
|
|
|
// in-order execution then succeeds
|
|
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");
|
|
});
|
|
|
|
it("flags a keyper whose alleged share is inconsistent with its commitment", async () => {
|
|
const { mempool, com } = ctx;
|
|
const epochId = 106;
|
|
const ep = C.epochIdentity(epochId);
|
|
await (await mempool.startEpoch(epochId, com.PK, ep.H1, ep.H1neg)).wait();
|
|
// report keyper 1 using keyper 0's share -> inconsistent -> flag emitted
|
|
await expect(mempool.reportInconsistentShare(epochId, 1, shareFor(com, ep, 0)))
|
|
.to.emit(mempool, "KeyperFlagged");
|
|
// a genuinely consistent share cannot be used to falsely accuse
|
|
await expect(mempool.reportInconsistentShare(epochId, 1, shareFor(com, ep, 1)))
|
|
.to.be.revertedWithCustomError(mempool, "ShareIsConsistent");
|
|
});
|
|
});
|