aere-contracts/test/storage-proof-verifier-poisoning-fix-2026-07-17.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

346 lines
18 KiB
JavaScript

// AereStorageProofVerifierV2 — regression tests for CANONICAL RECORD POISONING.
//
// KNOWN AND ALREADY DOCUMENTED (not the bug): AereStorageProofVerifier 0xF9a1A183…9362E proves
// a value against a GIVEN, prover-supplied state root and cannot itself show that root is
// canonical. That is the honest, intended scope of a storage-proof primitive.
//
// THE FINDING (2026-07-17 sweep): V1 wrote EVERY submission — anchored or not — into ONE shared
// mapping keyed only by (blockNumber, account, slot). So a record established through the
// trust-minimized path (AereStateRootAnchor.proveAgainstAnchor, which forces the proof's root to
// equal a root recovered from the BLOCKHASH opcode) can be SILENTLY OVERWRITTEN by anyone
// calling the unbound submitProof() with a proof against a state root of their OWN making for
// the SAME key.
//
// NOT THEORETICAL: the live V1 holds exactly such a record — AereTreasury 0x687933…6119 slot 0
// (owner) == Foundation 0x0243A4…f3C3 at block 8,915,939, against canonical stateRoot
// 0x515fce04…4a92f3 (verified live 2026-07-17, == eth_getBlockByNumber(8915939).stateRoot).
//
// THE FIX: anchored and unbound records live in SEPARATE mappings. Anchored is keyed by
// (blockNumber, account, slot) and writable ONLY by the immutable CANONICAL_ANCHOR; unbound is
// keyed by (SUBMITTER, blockNumber, account, slot) so every submitter has their own namespace.
// getProven() — the V1-shaped read — returns the ANCHORED record only: fail-closed.
//
// Each attack test is PAIRED with a control on the REAL V1 contract showing the poisoning
// SUCCEEDS there. Both sides use the REAL AereStateRootAnchor logic (via MockBlockhashAnchor,
// the existing test subclass that injects a canonical hash so a REAL AERE header outside the
// live 256-block window can be anchored) and the REAL AERE genesis header — the anchor's
// keccak-match + RLP state-root decode is genuine.
//
// The SP1 verifier is MOCKED (MockSp1Verifier: reverts unless the exact (vkey, publicValues,
// proof) triple was pre-registered — mirroring the real gateway). That is the CORRECT model of
// this attack: the attacker's storage proof is CRYPTOGRAPHICALLY VALID — they really did build
// a trie in which the slot holds their value, and the guest honestly proved it. The guest cannot
// know which root is canonical; that is exactly its documented scope. The bug was never a broken
// proof — it was letting an honestly-proven-but-uncanonical record land on a canonical key.
const { expect } = require("chai");
const { ethers } = require("hardhat");
const { encodeHeader } = require("./header-rlp");
const genesis = require("./fixtures/aere-genesis-header.json");
const VKEY = "0x" + "ab".repeat(32);
const PROOF = "0x4388a21c99";
const ATTACK_PROOF = "0x4388a21cAA"; // distinct bytes: the attacker's own valid proof
// The REAL live poisoning target (chain 2800, read 2026-07-17).
const TREASURY = "0x687933AE7ea4927867AC227F1b60d476003e6119";
const FOUNDATION = "0x0243A4f47D44b40b65D33f20329dE20D00c6f3C3";
const SLOT0 = ethers.ZeroHash;
const word = (addr) => ethers.zeroPadValue(ethers.getAddress(addr), 32);
function marker(vkey, pv, proof) {
return ethers.keccak256(ethers.AbiCoder.defaultAbiCoder().encode(["bytes32", "bytes", "bytes"], [vkey, pv, proof]));
}
describe("AereStorageProofVerifierV2 — canonical record-poisoning fix (2026-07-17)", function () {
let mock, v2, v2Anchor, v1, v1Anchor, deployer, attacker, chainId;
let CANONICAL_ROOT, GENESIS_HEADER;
const BN = 0n; // the real AERE genesis block
// abi.encode(chainId, blockNumber, stateRoot, account, slot, value) -> 192 bytes
function pv({ blockNumber = BN, stateRoot, account = TREASURY, slot = SLOT0, value, cid = chainId }) {
return ethers.AbiCoder.defaultAbiCoder().encode(
["uint256", "uint256", "bytes32", "address", "bytes32", "bytes32"],
[cid, blockNumber, stateRoot, account, slot, value]
);
}
async function accept(publicValues, proof = PROOF) {
await mock.setValid(marker(VKEY, publicValues, proof), true);
}
beforeEach(async function () {
[deployer, attacker] = await ethers.getSigners();
chainId = (await ethers.provider.getNetwork()).chainId;
GENESIS_HEADER = encodeHeader(genesis);
CANONICAL_ROOT = genesis.stateRoot;
mock = await (await ethers.getContractFactory("MockSp1Verifier")).deploy();
await mock.waitForDeployment();
const mockAddr = await mock.getAddress();
// --- V2 + its anchor. The two are mutually referential, so the verifier is deployed with
// the anchor's PREDICTED CREATE address and both directions are asserted afterwards.
const nonce = await ethers.provider.getTransactionCount(deployer.address);
const predictedAnchor = ethers.getCreateAddress({ from: deployer.address, nonce: nonce + 1 });
v2 = await (await ethers.getContractFactory("AereStorageProofVerifierV2")).deploy(mockAddr, VKEY, predictedAnchor);
await v2.waitForDeployment();
v2Anchor = await (await ethers.getContractFactory("MockBlockhashAnchor")).deploy(await v2.getAddress());
await v2Anchor.waitForDeployment();
expect(await v2Anchor.getAddress()).to.equal(predictedAnchor); // prediction held
// --- V1 + its anchor (the control).
v1 = await (await ethers.getContractFactory("AereStorageProofVerifier")).deploy(mockAddr, VKEY);
await v1.waitForDeployment();
v1Anchor = await (await ethers.getContractFactory("MockBlockhashAnchor")).deploy(await v1.getAddress());
await v1Anchor.waitForDeployment();
// Anchor the REAL AERE genesis header on both (its real canonical hash is injected because
// genesis is far outside the live 256-block BLOCKHASH window; the keccak match + RLP decode
// that follow are the genuine production logic).
for (const a of [v2Anchor, v1Anchor]) {
await a.setBlockHash(BN, genesis.hash);
await a.anchorRecentHeader(BN, GENESIS_HEADER);
}
});
/* ------------------------- fixture / wiring sanity ------------------------------ */
describe("wiring + real-data sanity", function () {
it("the REAL AERE genesis header reconstructs its canonical hash and state root", async function () {
expect(ethers.keccak256(GENESIS_HEADER)).to.equal(genesis.hash);
const a = await v2Anchor.getAnchoredRoot(BN);
expect(a[0]).to.equal(true);
expect(a[1]).to.equal(CANONICAL_ROOT); // decoded from the real header by the anchor
expect(a[2]).to.equal(genesis.hash);
});
it("the anchor <-> verifier binding is immutable and verified BOTH directions", async function () {
expect(await v2.CANONICAL_ANCHOR()).to.equal(await v2Anchor.getAddress());
expect(await v2Anchor.VERIFIER()).to.equal(await v2.getAddress());
expect(await v2.SP1_VERIFIER()).to.equal(await mock.getAddress());
expect(await v2.PROGRAM_VKEY()).to.equal(VKEY);
});
it("rejects a zero anchor / zero vkey / code-less verifier at construction", async function () {
const F = await ethers.getContractFactory("AereStorageProofVerifierV2");
const m = await mock.getAddress();
await expect(F.deploy(m, VKEY, ethers.ZeroAddress)).to.be.revertedWithCustomError(F, "ZeroAddress");
await expect(F.deploy(ethers.ZeroAddress, VKEY, deployer.address)).to.be.revertedWithCustomError(F, "ZeroAddress");
await expect(F.deploy(m, ethers.ZeroHash, deployer.address)).to.be.revertedWithCustomError(F, "ZeroVKey");
await expect(
F.deploy("0x000000000000000000000000000000000000dEaD", VKEY, deployer.address)
).to.be.revertedWithCustomError(F, "VerifierHasNoCode");
});
});
/* ================================================================================
THE ATTACK: an unbound proof overwrites a canonically-anchored record.
================================================================================ */
describe("ATTACK — unbound submitProof() poisons a canonically-anchored record", function () {
// The honest, anchored fact: Treasury slot0 == Foundation, proven against the CANONICAL root.
const honestPv = () => pv({ stateRoot: CANONICAL_ROOT, value: word(FOUNDATION) });
// The attacker's fact: same key, but proven against a root of THEIR OWN making, in which the
// Treasury owner is the attacker. The proof is genuinely valid for that root.
const ATTACKER_ROOT = "0x" + "de".repeat(32);
const attackPv = (who) => pv({ stateRoot: ATTACKER_ROOT, value: word(who) });
async function anchorHonest(anchor) {
const p = honestPv();
await accept(p);
await anchor.proveAgainstAnchor(p, PROOF);
return p;
}
it("PAIRED CONTROL: on V1 the attacker OVERWRITES the anchored record", async function () {
await anchorHonest(v1Anchor);
// the honest, anchored truth is on the books
let rec = await v1.getProven(BN, TREASURY, SLOT0);
expect(rec[0]).to.equal(true);
expect(rec[1]).to.equal(CANONICAL_ROOT);
expect(rec[2]).to.equal(word(FOUNDATION));
// ...until the attacker submits an unbound proof for the SAME key.
const ap = attackPv(attacker.address);
await accept(ap, ATTACK_PROOF);
await v1.connect(attacker).submitProof(ap, ATTACK_PROOF);
rec = await v1.getProven(BN, TREASURY, SLOT0);
expect(rec[0]).to.equal(true); // still "exists" — nothing looks wrong
expect(rec[1]).to.equal(ATTACKER_ROOT); // canonical root REPLACED
expect(rec[2]).to.equal(word(attacker.address)); // <-- POISONED: attacker is now "the owner"
expect(rec[2]).to.not.equal(word(FOUNDATION));
});
it("V2: the anchored record SURVIVES the identical attack — poisoning is closed", async function () {
await anchorHonest(v2Anchor);
let rec = await v2.getProven(BN, TREASURY, SLOT0);
expect(rec[2]).to.equal(word(FOUNDATION));
// the attacker's unbound submission SUCCEEDS (the API is not dropped)...
const ap = attackPv(attacker.address);
await accept(ap, ATTACK_PROOF);
await expect(v2.connect(attacker).submitProof(ap, ATTACK_PROOF)).to.emit(v2, "SlotProvenUnbound");
// ...but it lands in the ATTACKER'S OWN namespace and touches nothing canonical.
rec = await v2.getProven(BN, TREASURY, SLOT0);
expect(rec[0]).to.equal(true);
expect(rec[1]).to.equal(CANONICAL_ROOT); // untouched
expect(rec[2]).to.equal(word(FOUNDATION)); // <-- STILL the honest, anchored truth
expect(await v2.anchoredValue(BN, TREASURY, SLOT0)).to.equal(word(FOUNDATION));
expect(await v2.provenanceOf(BN, TREASURY, SLOT0)).to.equal(2); // Provenance.Anchored
// the attacker's record is readable, clearly non-canonical, and namespaced under them
const u = await v2.getUnboundProven(attacker.address, BN, TREASURY, SLOT0);
expect(u[0]).to.equal(true);
expect(u[1]).to.equal(ATTACKER_ROOT);
expect(u[2]).to.equal(word(attacker.address));
});
it("V2: submitProofWithExpectedRoot from a NON-anchor caller is ALSO non-canonical", async function () {
// The expected-root check is NOT self-securing: the caller picks the root, so it passes
// trivially. Provenance must therefore come from WHO calls, not from WHICH function.
await anchorHonest(v2Anchor);
const ap = attackPv(attacker.address);
await accept(ap, ATTACK_PROOF);
await v2.connect(attacker).submitProofWithExpectedRoot(ap, ATTACK_PROOF, ATTACKER_ROOT); // passes!
// ...and still cannot reach the canonical domain.
const rec = await v2.getProven(BN, TREASURY, SLOT0);
expect(rec[1]).to.equal(CANONICAL_ROOT);
expect(rec[2]).to.equal(word(FOUNDATION));
const u = await v2.getUnboundProven(attacker.address, BN, TREASURY, SLOT0);
expect(u[2]).to.equal(word(attacker.address));
});
it("PAIRED CONTROL: on V1 submitProofWithExpectedRoot poisons it just as well", async function () {
await anchorHonest(v1Anchor);
const ap = attackPv(attacker.address);
await accept(ap, ATTACK_PROOF);
await v1.connect(attacker).submitProofWithExpectedRoot(ap, ATTACK_PROOF, ATTACKER_ROOT);
const rec = await v1.getProven(BN, TREASURY, SLOT0);
expect(rec[2]).to.equal(word(attacker.address)); // POISONED
});
it("V2: two different attackers cannot overwrite EACH OTHER either", async function () {
const a1 = attackPv(attacker.address);
const a2 = pv({ stateRoot: "0x" + "cc".repeat(32), value: word(deployer.address) });
await accept(a1, ATTACK_PROOF);
await accept(a2, PROOF);
await v2.connect(attacker).submitProof(a1, ATTACK_PROOF);
await v2.connect(deployer).submitProof(a2, PROOF);
const u1 = await v2.getUnboundProven(attacker.address, BN, TREASURY, SLOT0);
const u2 = await v2.getUnboundProven(deployer.address, BN, TREASURY, SLOT0);
expect(u1[2]).to.equal(word(attacker.address)); // each keeps their own
expect(u2[2]).to.equal(word(deployer.address));
});
it("V2: an unbound record is INVISIBLE to the canonical read when nothing is anchored", async function () {
// fail-closed: no anchored record => getProven reports absent, even though an unbound
// record for that key exists. V1 would have reported the unbound record as if canonical.
const ap = attackPv(attacker.address);
await accept(ap, ATTACK_PROOF);
await v2.connect(attacker).submitProof(ap, ATTACK_PROOF);
const fresh = await v2.getProven(12345n, TREASURY, SLOT0);
expect(fresh[0]).to.equal(false);
const rec = await v2.getProven(BN, TREASURY, SLOT0); // BN was never anchored in this test
expect(rec[0]).to.equal(false); // <-- absent, NOT the attacker's value
expect(await v2.isAnchoredProven(BN, TREASURY, SLOT0)).to.equal(false);
await expect(v2.anchoredValue(BN, TREASURY, SLOT0)).to.be.revertedWithCustomError(v2, "NotAnchored");
});
it("PAIRED CONTROL: on V1 that same unbound record READS AS CANONICAL", async function () {
const ap = attackPv(attacker.address);
await accept(ap, ATTACK_PROOF);
await v1.connect(attacker).submitProof(ap, ATTACK_PROOF);
const rec = await v1.getProven(BN, TREASURY, SLOT0);
expect(rec[0]).to.equal(true); // indistinguishable from an anchored record
expect(rec[2]).to.equal(word(attacker.address));
});
});
/* ---------------- V2 does not over-reject: the anchored path works --------------- */
describe("V2 does not over-reject — the anchored path still works end to end", function () {
it("proveAgainstAnchor records a canonical, trust-minimized fact", async function () {
const p = pv({ stateRoot: CANONICAL_ROOT, value: word(FOUNDATION) });
await accept(p);
await expect(v2Anchor.proveAgainstAnchor(p, PROOF)).to.emit(v2, "SlotProvenAnchored");
const d = await v2.getAnchoredProven(BN, TREASURY, SLOT0);
expect(d[0]).to.equal(true);
expect(d[1]).to.equal(CANONICAL_ROOT);
expect(d[2]).to.equal(word(FOUNDATION));
expect(d[5]).to.equal(await v2Anchor.getAddress()); // submitter == the anchor
expect(await v2.provenanceOf(BN, TREASURY, SLOT0)).to.equal(2); // Anchored
});
it("the anchor REFUSES a proof whose root is not the canonical one", async function () {
const bad = pv({ stateRoot: "0x" + "de".repeat(32), value: word(FOUNDATION) });
await accept(bad);
await expect(v2Anchor.proveAgainstAnchor(bad, PROOF)).to.be.revertedWithCustomError(
v2Anchor,
"RootNotCanonical"
);
expect((await v2.getProven(BN, TREASURY, SLOT0))[0]).to.equal(false);
});
it("the anchor REFUSES a proof for a block it has not anchored", async function () {
const p = pv({ blockNumber: 999n, stateRoot: CANONICAL_ROOT, value: word(FOUNDATION) });
await accept(p);
await expect(v2Anchor.proveAgainstAnchor(p, PROOF)).to.be.revertedWithCustomError(v2Anchor, "NotAnchored");
});
it("re-anchoring the same canonical fact is idempotent", async function () {
const p = pv({ stateRoot: CANONICAL_ROOT, value: word(FOUNDATION) });
await accept(p);
await v2Anchor.proveAgainstAnchor(p, PROOF);
await v2Anchor.proveAgainstAnchor(p, PROOF); // no conflict: same canonical root
expect((await v2.getProven(BN, TREASURY, SLOT0))[2]).to.equal(word(FOUNDATION));
});
});
/* ---------------- proof + chain integrity still enforced ------------------------- */
describe("proof + chain integrity (unchanged from V1)", function () {
it("an invalid proof reverts InvalidProof on every entry point", async function () {
const p = pv({ stateRoot: CANONICAL_ROOT, value: word(FOUNDATION) });
await expect(v2.submitProof(p, PROOF)).to.be.revertedWithCustomError(v2, "InvalidProof");
await expect(v2.submitProofWithExpectedRoot(p, PROOF, CANONICAL_ROOT)).to.be.revertedWithCustomError(
v2,
"InvalidProof"
);
await expect(v2.verify(p, PROOF)).to.be.revertedWithCustomError(v2, "InvalidProof");
});
it("a wrong chainId reverts WrongChain", async function () {
const p = pv({ stateRoot: CANONICAL_ROOT, value: word(FOUNDATION), cid: 1n });
await accept(p);
await expect(v2.submitProof(p, PROOF)).to.be.revertedWithCustomError(v2, "WrongChain").withArgs(1n, chainId);
});
it("a wrong-length publicValues reverts BadPublicValuesLength", async function () {
await expect(v2.submitProof("0x1234", PROOF)).to.be.revertedWithCustomError(v2, "BadPublicValuesLength").withArgs(2);
});
it("submitProofWithExpectedRoot still enforces the expected root", async function () {
const p = pv({ stateRoot: CANONICAL_ROOT, value: word(FOUNDATION) });
await accept(p);
await expect(v2.submitProofWithExpectedRoot(p, PROOF, "0x" + "11".repeat(32)))
.to.be.revertedWithCustomError(v2, "StateRootMismatch")
.withArgs(CANONICAL_ROOT, "0x" + "11".repeat(32));
});
it("verify() is unchanged and still returns the decoded tuple (the anchor depends on it)", async function () {
const p = pv({ stateRoot: CANONICAL_ROOT, value: word(FOUNDATION) });
await accept(p);
const out = await v2.verify(p, PROOF);
expect(out[0]).to.equal(BN);
expect(out[1]).to.equal(CANONICAL_ROOT);
expect(out[2]).to.equal(TREASURY);
expect(out[4]).to.equal(word(FOUNDATION));
});
});
});