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.
346 lines
18 KiB
JavaScript
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));
|
|
});
|
|
});
|
|
});
|