aere-contracts/test/history-state-root-anchor-double-verify.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

282 lines
11 KiB
JavaScript

// FINDING 4 (low): AereHistoryStateRootAnchor.proveAgainstAnchor ran the full
// SP1 Groth16 verification TWICE on identical inputs (once via VERIFIER.verify
// to read the block number, then again inside submitProofWithExpectedRoot).
//
// This suite:
// 1. Reproduces the redundant verification against the ORIGINAL V1 contract
// using a verifier whose verify() reverts. V1's proveAgainstAnchor still
// calls verify(), so the call reverts, witnessing the redundant path.
// 2. Proves the corrected V2 contract derives the block number locally and
// calls ONLY submitProofWithExpectedRoot, so exactly ONE verification runs
// (submitCalls == 1) while behaviour is unchanged: a valid proof records and
// tampered / invalid inputs revert.
//
// The anchored root is REAL: the AERE genesis header (chain 2800) is
// reconstructed and its real state root is anchored, then proven against.
const { expect } = require("chai");
const { ethers } = require("hardhat");
const { encodeHeader } = require("./header-rlp");
const genesis = require("./fixtures/aere-genesis-header.json");
const ZERO32 = "0x" + "00".repeat(32);
const BLOCK = 5000n; // any block number; the mock injects its canonical hash
const BLOCK2 = 6000n; // a SECOND anchored block with a DIFFERENT state root
// abi.encode(chainId, blockNumber, stateRoot, account, slot, value) -> 192 bytes
function publicValues({ chainId = 2800n, blockNumber, stateRoot, account, slot, value }) {
return ethers.AbiCoder.defaultAbiCoder().encode(
["uint256", "uint256", "bytes32", "address", "bytes32", "bytes32"],
[chainId, blockNumber, stateRoot, account, slot, value]
);
}
describe("AereHistoryStateRootAnchor FINDING 4 (double SP1 verify)", function () {
const account = ethers.getAddress("0x00000000000000000000000000000000000000a1");
const slot = "0x" + "00".repeat(31) + "05";
const value = "0x" + "00".repeat(31) + "2a";
let verifier;
async function deployVerifier() {
const V = await ethers.getContractFactory("CountingStorageProofVerifier");
const v = await V.deploy();
await v.waitForDeployment();
return v;
}
async function deployAnchor(factoryName, verifierAddr) {
const F = await ethers.getContractFactory(factoryName);
const a = await F.deploy(verifierAddr);
await a.waitForDeployment();
return a;
}
// Anchor the REAL genesis state root at BLOCK on the given anchor.
async function anchorGenesis(anchor) {
const header = encodeHeader(genesis);
await anchor.setHistoryBlockHash(BLOCK, genesis.hash);
await anchor.anchorHistoricalHeader(BLOCK, header);
expect(await anchor.anchoredStateRoot(BLOCK)).to.equal(genesis.stateRoot);
}
// Anchor a SECOND, distinct canonical block at BLOCK2 whose state root differs
// from genesis. We reuse the real genesis header but overwrite only its
// stateRoot field, so the header re-encodes to a NEW keccak (a new canonical
// hash the mock injects) and anchors a different-but-well-formed root. This
// gives us two anchored blocks with different roots to test cross-block
// confusion.
const OTHER_ROOT =
"0x1111111111111111111111111111111111111111111111111111111111111111";
async function anchorSecondBlock(anchor) {
const header2 = encodeHeader({ ...genesis, stateRoot: OTHER_ROOT });
const hash2 = ethers.keccak256(header2);
await anchor.setHistoryBlockHash(BLOCK2, hash2);
await anchor.anchorHistoricalHeader(BLOCK2, header2);
expect(await anchor.anchoredStateRoot(BLOCK2)).to.equal(OTHER_ROOT);
}
beforeEach(async function () {
verifier = await deployVerifier();
});
describe("reproduction: V1 makes the redundant verify() call", function () {
it("proveAgainstAnchor reverts because V1 still calls verify() (double verification)", async function () {
const anchor = await deployAnchor("MockHistoryAnchorV1", await verifier.getAddress());
await anchorGenesis(anchor);
const pv = publicValues({
blockNumber: BLOCK,
stateRoot: genesis.stateRoot,
account,
slot,
value,
});
// The counting verifier rejects any verify() call. V1 calls verify()
// first, so the whole tx reverts: this is the redundant verification the
// finding describes. On a real verifier the call would instead succeed and
// burn a full extra Groth16 pairing.
await expect(anchor.proveAgainstAnchor(pv, "0x")).to.be.revertedWithCustomError(
verifier,
"RedundantVerifyCall"
);
// No verification ever reached the recording path.
expect(await verifier.submitCalls()).to.equal(0n);
});
});
describe("fix: V2 verifies exactly once", function () {
let anchor;
beforeEach(async function () {
anchor = await deployAnchor("MockHistoryAnchorV2", await verifier.getAddress());
await anchorGenesis(anchor);
});
it("records a valid proof running submitProofWithExpectedRoot ONCE and never verify()", async function () {
const pv = publicValues({
blockNumber: BLOCK,
stateRoot: genesis.stateRoot,
account,
slot,
value,
});
await expect(anchor.proveAgainstAnchor(pv, "0x"))
.to.emit(anchor, "ProvenAgainstAnchor")
.withArgs(BLOCK, genesis.stateRoot, account, slot, value);
// Exactly one verification, via the recording path only.
expect(await verifier.submitCalls()).to.equal(1n);
expect(await verifier.recorded(ethers.keccak256(pv))).to.equal(true);
});
it("reverts when the committed root is tampered to differ from the anchored root", async function () {
const pv = publicValues({
blockNumber: BLOCK,
stateRoot: ZERO32, // not the anchored genesis root
account,
slot,
value,
});
// submit binds committed root to anchored root; mismatch reverts.
await expect(anchor.proveAgainstAnchor(pv, "0x")).to.be.revertedWith("StateRootMismatch");
expect(await verifier.submitCalls()).to.equal(0n);
});
it("reverts NotAnchored when the proof's block number was never anchored", async function () {
const pv = publicValues({
blockNumber: 999999n,
stateRoot: genesis.stateRoot,
account,
slot,
value,
});
await expect(anchor.proveAgainstAnchor(pv, "0x")).to.be.revertedWithCustomError(
anchor,
"NotAnchored"
);
// Reverts before any verification.
expect(await verifier.submitCalls()).to.equal(0n);
});
it("reverts BadPublicValuesLength for a non-192-byte payload", async function () {
const short = "0x" + "11".repeat(160); // 160 bytes, one word short
await expect(anchor.proveAgainstAnchor(short, "0x")).to.be.revertedWithCustomError(
anchor,
"BadPublicValuesLength"
);
});
it("reverts when the underlying SP1 proof is invalid (verification still gates recording)", async function () {
await verifier.setValidate(false);
const pv = publicValues({
blockNumber: BLOCK,
stateRoot: genesis.stateRoot,
account,
slot,
value,
});
await expect(anchor.proveAgainstAnchor(pv, "0x")).to.be.reverted;
expect(await verifier.submitCalls()).to.equal(0n);
});
it("decodes block number, account, and slot locally from the 192-byte layout", async function () {
const account2 = ethers.getAddress("0x00000000000000000000000000000000000000b2");
const slot2 = "0x" + "00".repeat(31) + "09";
const value2 = "0x" + "00".repeat(30) + "0100";
const pv = publicValues({
blockNumber: BLOCK,
stateRoot: genesis.stateRoot,
account: account2,
slot: slot2,
value: value2,
});
await expect(anchor.proveAgainstAnchor(pv, "0x"))
.to.emit(anchor, "ProvenAgainstAnchor")
.withArgs(BLOCK, genesis.stateRoot, account2, slot2, value2);
expect(await verifier.submitCalls()).to.equal(1n);
});
});
// RESIDUAL ATTACK VARIANT (cross-block root confusion).
//
// The V2 fix selects the anchored root using the blockNumber it decodes
// LOCALLY from public-values word 1, and no longer cross-checks any block
// number returned by VERIFIER.verify (that call is gone). So the sharpest
// residual question is: can an attacker take a proof that is genuinely valid
// for anchored block A and redirect it against a DIFFERENT anchored block B
// (word 1 = B) to get an off-root value recorded? It must NOT: the single
// submitProofWithExpectedRoot binds the proof's committed root (word 2) to
// anchored[B]'s root, so the tampered block number reverts, and the exactly-
// once property must hold (submitCalls stays 0 on the failed attack, and a
// later honest proof still records exactly once with no verify() call).
describe("residual attack variant: cross-block root confusion", function () {
it("V1 (reproduction) still runs the redundant verify() on the cross-block attempt", async function () {
const anchor = await deployAnchor("MockHistoryAnchorV1", await verifier.getAddress());
await anchorGenesis(anchor);
await anchorSecondBlock(anchor);
// Proof genuinely valid for BLOCK (root = genesis root) but word 1 tampered
// to BLOCK2 (anchored to OTHER_ROOT).
const pv = publicValues({
blockNumber: BLOCK2,
stateRoot: genesis.stateRoot,
account,
slot,
value,
});
// V1 calls verify() FIRST, so it reverts on the redundant path rather than
// through the single submit binding. This witnesses the double-verify the
// finding removes.
await expect(anchor.proveAgainstAnchor(pv, "0x")).to.be.revertedWithCustomError(
verifier,
"RedundantVerifyCall"
);
expect(await verifier.submitCalls()).to.equal(0n);
});
it("V2 (closed) rejects the cross-block attempt through the single submit binding, no verify(), no record", async function () {
const anchor = await deployAnchor("MockHistoryAnchorV2", await verifier.getAddress());
await anchorGenesis(anchor);
await anchorSecondBlock(anchor);
const attackPv = publicValues({
blockNumber: BLOCK2, // redirect a genesis-root proof at the OTHER_ROOT block
stateRoot: genesis.stateRoot,
account,
slot,
value,
});
// Anchored[BLOCK2] = OTHER_ROOT, committed root = genesis root: submit binds
// them and reverts. No fallback to verify(), and nothing recorded.
await expect(anchor.proveAgainstAnchor(attackPv, "0x")).to.be.revertedWith(
"StateRootMismatch"
);
expect(await verifier.submitCalls()).to.equal(0n);
expect(await verifier.recorded(ethers.keccak256(attackPv))).to.equal(false);
// The failed attack corrupts nothing: an honest proof for BLOCK2's real
// root still records, and does so with EXACTLY ONE verification.
const honestPv = publicValues({
blockNumber: BLOCK2,
stateRoot: OTHER_ROOT,
account,
slot,
value,
});
await expect(anchor.proveAgainstAnchor(honestPv, "0x"))
.to.emit(anchor, "ProvenAgainstAnchor")
.withArgs(BLOCK2, OTHER_ROOT, account, slot, value);
expect(await verifier.submitCalls()).to.equal(1n);
expect(await verifier.recorded(ethers.keccak256(honestPv))).to.equal(true);
});
});
});