aere-contracts/test/AereRecoveryRegistry.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

289 lines
14 KiB
JavaScript

const { expect } = require("chai");
const { ethers, network } = require("hardhat");
// ---------------------------------------------------------------------------
// AereRecoveryRegistry test.
//
// Hardhat has no Falcon-512 precompile, so we install the repo's MockPQCPrecompile
// (scheme 1) at 0x0AE1 via hardhat_setCode. The mock parses the input at the LIVE
// precompile's spec offsets and accepts iff commitment == keccak256(pk || message).
// So a positive record here proves the contract built the 0x0AE1 input at the correct
// offsets and enforces fail-closed / append-only semantics.
//
// [MEASURE] The REAL precompile path (that the on-chain 0x0AE1 accepts a genuine
// Falcon-512 signature) is exercised separately against LIVE mainnet 2800 via eth_call
// (scripts/pqc/verify-live-precompile.js) and is NOT re-proven inside this unit test.
// The contract stays hard-wired to 0x0AE1 for production; the test only swaps in mock
// bytecode at that address.
// ---------------------------------------------------------------------------
const FALCON512 = ethers.getAddress("0x0000000000000000000000000000000000000ae1");
const PK_LEN = 897;
const PK_HEADER = 0x09;
const ESIG_HEADER = 0x29;
const FAULT_CRASH = 1;
const FAULT_NETSPLIT = 2;
const FAULT_BYZANTINE = 3;
// Deterministic seeded RNG (keccak hash-chain) so the test is fully reproducible.
function makeRng(seedText) {
let seed = ethers.keccak256(ethers.toUtf8Bytes(seedText));
function bytes(n) {
const out = new Uint8Array(n);
let i = 0;
while (i < n) {
seed = ethers.keccak256(seed);
const chunk = ethers.getBytes(seed);
for (let j = 0; j < chunk.length && i < n; j++, i++) out[i] = chunk[j];
}
return out;
}
return { bytes };
}
function makePubKey(rng) {
const raw = rng.bytes(PK_LEN);
raw[0] = PK_HEADER; // Falcon-512 requires the header byte
return ethers.hexlify(raw);
}
// commitment the mock expects for a genuine signature over `message` by `pubKey`.
function commitmentFor(pubKey, message) {
return ethers.keccak256(ethers.concat([pubKey, message]));
}
// A genuine Falcon-512 signature envelope binding to `message` under `pubKey`:
// envelope = nonce(40) || esig ; esig = 0x29 || commitment(32) => 73 bytes.
function genuine(pubKey, message, rng) {
const nonce = rng.bytes(40);
const esig = ethers.concat([new Uint8Array([ESIG_HEADER]), commitmentFor(pubKey, message)]);
return ethers.hexlify(ethers.concat([nonce, esig]));
}
// Flip one byte inside the commitment region (esig[1] at offset 41). This is the
// region the precompile binds the (key, message) pair to, so a flip is a genuine forgery.
function tamperCommitment(sigHex) {
const b = ethers.getBytes(sigHex);
b[41] ^= 0x01;
return ethers.hexlify(b);
}
// N=7 fault-boundary context used across the suite (matches the security spec: quorum 5, f=2).
const N7 = { n: 7, q: 5, aliveKill2: 5, aliveKill3: 4 };
describe("AereRecoveryRegistry", function () {
let reg, mockCode;
before(async function () {
const F = await ethers.getContractFactory("MockPQCPrecompile");
const mock = await F.deploy(1); // scheme 1 = Falcon-512
await mock.waitForDeployment();
mockCode = await ethers.provider.getCode(await mock.getAddress());
});
beforeEach(async function () {
await network.provider.send("hardhat_setCode", [FALCON512, mockCode]);
const R = await ethers.getContractFactory("AereRecoveryRegistry");
reg = await R.deploy();
await reg.waitForDeployment();
// Advance block height so historical halt/resume heights are <= block.number.
await network.provider.send("hardhat_mine", ["0x400"]); // +1024 blocks
});
async function registerOp(pubKey) {
const tx = await reg.registerOperator(pubKey);
const rc = await tx.wait();
const log = rc.logs.map((l) => reg.interface.parseLog(l)).find((p) => p && p.name === "OperatorRegistered");
return Number(log.args.operatorId);
}
// Build a signed submitRecovery call for a genuine record under `operatorId`.
async function signedArgs(operatorId, pubKey, rng, f) {
const nonce = await reg.nonceOf(operatorId);
const recordHash = await reg.recordChallenge(
operatorId, nonce, f.halt, f.resume, f.kind, f.n, f.q, f.alive
);
const sig = genuine(pubKey, recordHash, rng);
return [operatorId, f.halt, f.resume, f.kind, f.n, f.q, f.alive, sig];
}
// -----------------------------------------------------------------------
// Operator registration
// -----------------------------------------------------------------------
describe("registerOperator", function () {
it("accepts a well-formed Falcon-512 key and assigns sequential ids", async function () {
const rng = makeRng("reg");
for (let i = 0; i < 3; i++) {
const pk = makePubKey(rng);
const id = await registerOp(pk);
expect(id).to.equal(i);
const op = await reg.getOperator(id);
expect(op.pubKey).to.equal(pk);
expect(Number(op.nonce)).to.equal(0);
}
expect(await reg.operatorCount()).to.equal(3n);
});
it("rejects a wrong-length or wrong-header key", async function () {
await expect(reg.registerOperator("0x" + "09" + "00".repeat(895))).to.be.revertedWithCustomError(reg, "InvalidPubKey"); // 896 bytes
await expect(reg.registerOperator("0x" + "0a" + "00".repeat(896))).to.be.revertedWithCustomError(reg, "InvalidPubKey"); // wrong header
});
});
// -----------------------------------------------------------------------
// Happy path: a valid attestation records
// -----------------------------------------------------------------------
describe("submitRecovery (valid attestation records)", function () {
it("records a genuine kill-2 (f=2 boundary) crash recovery and stores every field", async function () {
const rng = makeRng("happy");
const pk = makePubKey(rng);
const operatorId = await registerOp(pk);
const f = { halt: 1000, resume: 1013, kind: FAULT_CRASH, n: N7.n, q: N7.q, alive: N7.aliveKill2 };
const before = await reg.recordCount();
const args = await signedArgs(operatorId, pk, rng, f);
await expect(reg.submitRecovery(...args)).to.emit(reg, "RecoveryAttested");
expect(await reg.recordCount()).to.equal(before + 1n);
expect(Number(await reg.nonceOf(operatorId))).to.equal(1);
const r = await reg.getRecord(0);
expect(Number(r.haltHeight)).to.equal(1000);
expect(Number(r.resumeHeight)).to.equal(1013);
expect(Number(r.faultKind)).to.equal(FAULT_CRASH);
expect(Number(r.nValidators)).to.equal(7);
expect(Number(r.quorum)).to.equal(5);
expect(Number(r.aliveDuringFault)).to.equal(5);
expect(Number(r.operatorId)).to.equal(operatorId);
expect(Number(r.nonce)).to.equal(0);
});
it("records a netsplit and a byzantine-proposer recovery under the same operator (nonce advances)", async function () {
const rng = makeRng("kinds");
const pk = makePubKey(rng);
const operatorId = await registerOp(pk);
await reg.submitRecovery(
...(await signedArgs(operatorId, pk, rng, { halt: 500, resume: 540, kind: FAULT_NETSPLIT, n: 7, q: 5, alive: 4 }))
);
await reg.submitRecovery(
...(await signedArgs(operatorId, pk, rng, { halt: 600, resume: 612, kind: FAULT_BYZANTINE, n: 7, q: 5, alive: 5 }))
);
expect(await reg.recordCount()).to.equal(2n);
expect(Number(await reg.nonceOf(operatorId))).to.equal(2);
expect(Number((await reg.getRecord(0)).faultKind)).to.equal(FAULT_NETSPLIT);
expect(Number((await reg.getRecord(1)).faultKind)).to.equal(FAULT_BYZANTINE);
});
});
// -----------------------------------------------------------------------
// Fail-closed: a tampered attestation is rejected
// -----------------------------------------------------------------------
describe("fail-closed rejection (nothing recorded)", function () {
it("rejects a tampered signature and records nothing", async function () {
const rng = makeRng("tamper");
const pk = makePubKey(rng);
const operatorId = await registerOp(pk);
const f = { halt: 1000, resume: 1013, kind: FAULT_CRASH, n: 7, q: 5, alive: 5 };
const args = await signedArgs(operatorId, pk, rng, f);
args[7] = tamperCommitment(args[7]); // corrupt the signature
await expect(reg.submitRecovery(...args)).to.be.revertedWithCustomError(reg, "PQCVerificationFailed");
expect(await reg.recordCount()).to.equal(0n);
expect(Number(await reg.nonceOf(operatorId))).to.equal(0);
});
it("rejects a signature bound to DIFFERENT record fields (heights / fault kind)", async function () {
const rng = makeRng("bind");
const pk = makePubKey(rng);
const operatorId = await registerOp(pk);
// Sign a record for kind=CRASH at 1000..1013, then try to submit BYZANTINE with the same sig.
const signed = await signedArgs(operatorId, pk, rng, { halt: 1000, resume: 1013, kind: FAULT_CRASH, n: 7, q: 5, alive: 5 });
const sig = signed[7];
await expect(
reg.submitRecovery(operatorId, 1000, 1013, FAULT_BYZANTINE, 7, 5, 5, sig)
).to.be.revertedWithCustomError(reg, "PQCVerificationFailed");
// ... and a shifted resume height, same sig.
await expect(
reg.submitRecovery(operatorId, 1000, 1099, FAULT_CRASH, 7, 5, 5, sig)
).to.be.revertedWithCustomError(reg, "PQCVerificationFailed");
expect(await reg.recordCount()).to.equal(0n);
});
it("treats an empty precompile return (pre-fork / stale chain) as invalid", async function () {
const rng = makeRng("stale");
const pk = makePubKey(rng);
const operatorId = await registerOp(pk);
const args = await signedArgs(operatorId, pk, rng, { halt: 1000, resume: 1013, kind: FAULT_CRASH, n: 7, q: 5, alive: 5 });
// Wipe the precompile: an empty account returns success + empty returndata.
await network.provider.send("hardhat_setCode", [FALCON512, "0x"]);
await expect(reg.submitRecovery(...args)).to.be.revertedWithCustomError(reg, "PQCVerificationFailed");
expect(await reg.recordCount()).to.equal(0n);
});
it("reverts on unknown operator, invalid fault kind, bad heights, and bad validator set", async function () {
const rng = makeRng("validate");
const pk = makePubKey(rng);
const operatorId = await registerOp(pk);
const dummySig = "0x" + "00".repeat(73);
const cur = await ethers.provider.getBlockNumber();
await expect(reg.submitRecovery(99, 1000, 1013, FAULT_CRASH, 7, 5, 5, dummySig)).to.be.revertedWithCustomError(reg, "UnknownOperator");
await expect(reg.submitRecovery(operatorId, 1000, 1013, 0, 7, 5, 5, dummySig)).to.be.revertedWithCustomError(reg, "InvalidFaultKind");
await expect(reg.submitRecovery(operatorId, 1000, 1013, 4, 7, 5, 5, dummySig)).to.be.revertedWithCustomError(reg, "InvalidFaultKind");
await expect(reg.submitRecovery(operatorId, 0, 10, FAULT_CRASH, 7, 5, 5, dummySig)).to.be.revertedWithCustomError(reg, "InvalidHeights");
await expect(reg.submitRecovery(operatorId, 1013, 1000, FAULT_CRASH, 7, 5, 5, dummySig)).to.be.revertedWithCustomError(reg, "InvalidHeights"); // resume < halt
await expect(reg.submitRecovery(operatorId, 1000, cur + 1000, FAULT_CRASH, 7, 5, 5, dummySig)).to.be.revertedWithCustomError(reg, "InvalidHeights"); // future resume
await expect(reg.submitRecovery(operatorId, 1000, 1013, FAULT_CRASH, 5, 7, 5, dummySig)).to.be.revertedWithCustomError(reg, "InvalidValidatorSet"); // quorum > n
await expect(reg.submitRecovery(operatorId, 1000, 1013, FAULT_CRASH, 7, 5, 9, dummySig)).to.be.revertedWithCustomError(reg, "InvalidValidatorSet"); // alive > n
expect(await reg.recordCount()).to.equal(0n);
});
});
// -----------------------------------------------------------------------
// Append-only: cannot overwrite
// -----------------------------------------------------------------------
describe("append-only (cannot overwrite)", function () {
it("appends new records without ever rewriting an earlier record, and rejects replay", async function () {
const rng = makeRng("append");
const pk = makePubKey(rng);
const operatorId = await registerOp(pk);
// Record 0.
const args0 = await signedArgs(operatorId, pk, rng, { halt: 1000, resume: 1013, kind: FAULT_CRASH, n: 7, q: 5, alive: 5 });
await reg.submitRecovery(...args0);
const r0before = await reg.getRecord(0);
// Record 1 (nonce has advanced to 1).
const args1 = await signedArgs(operatorId, pk, rng, { halt: 2000, resume: 2050, kind: FAULT_NETSPLIT, n: 7, q: 5, alive: 4 });
await reg.submitRecovery(...args1);
expect(await reg.recordCount()).to.equal(2n);
// Record 0 is byte-for-byte unchanged after record 1 was appended.
const r0after = await reg.getRecord(0);
expect(r0after.recordHash).to.equal(r0before.recordHash);
expect(Number(r0after.haltHeight)).to.equal(1000);
expect(Number(r0after.resumeHeight)).to.equal(1013);
expect(Number(r0after.faultKind)).to.equal(FAULT_CRASH);
// Record 1 landed at a fresh id.
expect(Number((await reg.getRecord(1)).haltHeight)).to.equal(2000);
// Replay of the exact consumed (record 0) signature now fails: the challenge uses nonce 2.
await expect(reg.submitRecovery(...args0)).to.be.revertedWithCustomError(reg, "PQCVerificationFailed");
expect(await reg.recordCount()).to.equal(2n);
// Record 0 still unchanged after the replay attempt.
expect((await reg.getRecord(0)).recordHash).to.equal(r0before.recordHash);
});
it("exposes no function that can overwrite or delete a record", async function () {
// Structural check: the ABI has no setter/updater/delete entry point for records.
const names = reg.interface.fragments.filter((f) => f.type === "function").map((f) => f.name.toLowerCase());
for (const forbidden of ["set", "update", "edit", "delete", "remove", "overwrite", "rewrite", "setrecord", "admin", "owner"]) {
expect(names.some((n) => n.includes(forbidden))).to.equal(false);
}
});
});
});