aere-contracts/test/evm-validity-canonical-binding-2026-07.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

272 lines
11 KiB
JavaScript

const { expect } = require("chai");
const { ethers } = require("hardhat");
// FINDING 3 (medium): EVM-validity canonical binding.
//
// V1 (AereEVMValidity / AereEVMValidityBatch) records an SP1 postStateRoot as a
// block WITHOUT binding it to the real chain: no blockHash/parentHash in the
// public values, no blockhash() check, no inter-record continuity. A genuine SP1
// proof of a FABRICATED execution can therefore be recorded as "canonical", and
// first-write-wins dedup lets an attacker squat slot N.
//
// This suite (1) reproduces the attack on V1, then (2) proves V2 closes it:
// - a fabricated proof with a wrong/absent committed blockHash is REJECTED,
// - a real in-window proof with the correct committed blockHash is accepted,
// - batch V2 enforces inter-record continuity.
//
// The SP1 verifier is mocked (MockSp1Verifier: verifyProof reverts unless the
// exact (vkey, publicValues, proof) triple was pre-marked valid), and blockhash
// binding is exercised against real hardhat block hashes.
const coder = ethers.AbiCoder.defaultAbiCoder();
const VKEY = "0x" + "ab".repeat(32);
const PROOF = "0x4388a21c" + "00".repeat(64); // dummy selector-prefixed bytes
const ZERO32 = "0x" + "00".repeat(32);
function marker(vkey, pv, proof) {
return ethers.keccak256(
coder.encode(["bytes32", "bytes", "bytes"], [vkey, pv, proof])
);
}
// V1 single-block public values: 7 words.
function encodeV1(o) {
return coder.encode(
["uint256", "uint256", "bytes32", "bytes32", "bytes32", "uint256", "uint256"],
[o.chainId, o.blockNumber, o.prevStateRoot, o.postStateRoot, o.txRoot, o.gasUsed, o.numTxns]
);
}
// V2 single-block public values: 9 words (adds parentHash + blockHash).
function encodeV2(o) {
return coder.encode(
["uint256", "uint256", "bytes32", "bytes32", "bytes32", "bytes32", "bytes32", "uint256", "uint256"],
[o.chainId, o.blockNumber, o.parentHash, o.blockHash, o.prevStateRoot, o.postStateRoot, o.txRoot, o.gasUsed, o.numTxns]
);
}
// V2 batch public values: 10 words.
function encodeBatchV2(o) {
return coder.encode(
["uint256", "uint256", "uint256", "uint256", "bytes32", "bytes32", "bytes32", "bytes32", "uint256", "uint256"],
[o.chainId, o.firstBlock, o.lastBlock, o.numBlocks, o.firstParentHash, o.lastBlockHash, o.prevStateRoot, o.postStateRoot, o.totalGas, o.numTxns]
);
}
const FAKE_POST = "0x" + "fa".repeat(32);
const PREV_ROOT = "0x" + "11".repeat(32);
const TX_ROOT = "0x" + "22".repeat(32);
describe("FINDING 3 - EVM-validity canonical binding", () => {
let mock, chainId;
beforeEach(async () => {
const M = await ethers.getContractFactory("MockSp1Verifier");
mock = await M.deploy();
await mock.waitForDeployment();
chainId = (await ethers.provider.getNetwork()).chainId; // 31337 on hardhat
});
// ------------------------------------------------------------------ V1 hole
describe("V1 attack surface (documents the vulnerability)", () => {
let v1;
beforeEach(async () => {
const F = await ethers.getContractFactory("AereEVMValidity");
v1 = await F.deploy(await mock.getAddress(), VKEY);
await v1.waitForDeployment();
});
it("records a FABRICATED execution as canonical (no blockhash binding)", async () => {
// A genuine-for-vkey proof whose committed postStateRoot is fabricated and
// whose blockNumber never existed as claimed. V1 has no way to tell.
const pv = encodeV1({
chainId, blockNumber: 999_000, prevStateRoot: PREV_ROOT,
postStateRoot: FAKE_POST, txRoot: TX_ROOT, gasUsed: 21000, numTxns: 1,
});
await mock.setValid(marker(VKEY, pv, PROOF), true);
await v1.recordBlock(pv, PROOF); // ATTACK SUCCEEDS: no revert
expect(await v1.provenCount()).to.equal(1n);
const rec = await v1.getRecord(0);
expect(rec[2]).to.equal(FAKE_POST); // fabricated root anchored as canonical
});
it("lets an attacker SQUAT slot N via first-write-wins", async () => {
const pvFake = encodeV1({
chainId, blockNumber: 999_000, prevStateRoot: PREV_ROOT,
postStateRoot: FAKE_POST, txRoot: TX_ROOT, gasUsed: 21000, numTxns: 1,
});
await mock.setValid(marker(VKEY, pvFake, PROOF), true);
await v1.recordBlock(pvFake, PROOF);
// A later, DIFFERENT (even honest) proof for the same height is locked out.
const pvReal = encodeV1({
chainId, blockNumber: 999_000, prevStateRoot: PREV_ROOT,
postStateRoot: "0x" + "cc".repeat(32), txRoot: TX_ROOT, gasUsed: 42000, numTxns: 2,
});
await mock.setValid(marker(VKEY, pvReal, PROOF), true);
await expect(v1.recordBlock(pvReal, PROOF)).to.be.revertedWithCustomError(
v1, "AlreadyProven"
);
});
});
// ------------------------------------------------------------------ V2 fix
describe("V2 single-block canonical binding", () => {
let v2;
beforeEach(async () => {
const F = await ethers.getContractFactory("AereEVMValidityV2");
v2 = await F.deploy(await mock.getAddress(), VKEY);
await v2.waitForDeployment();
});
it("REJECTS a fabricated in-window block whose committed blockHash is wrong", async () => {
const bn = await ethers.provider.getBlockNumber();
const parent = await ethers.provider.getBlock(bn - 1);
const pv = encodeV2({
chainId, blockNumber: bn,
parentHash: parent.hash,
blockHash: "0x" + "de".repeat(32), // WRONG: not the real hash of block bn
prevStateRoot: PREV_ROOT, postStateRoot: FAKE_POST, txRoot: TX_ROOT,
gasUsed: 21000, numTxns: 1,
});
await mock.setValid(marker(VKEY, pv, PROOF), true);
await expect(v2.recordBlock(pv, PROOF)).to.be.revertedWithCustomError(
v2, "BlockHashMismatch"
);
});
it("REJECTS a block whose hash is unavailable (out of window / future)", async () => {
const bn = await ethers.provider.getBlockNumber();
const pv = encodeV2({
chainId, blockNumber: bn + 5000, // future: blockhash() == 0
parentHash: ZERO32, blockHash: "0x" + "de".repeat(32),
prevStateRoot: PREV_ROOT, postStateRoot: FAKE_POST, txRoot: TX_ROOT,
gasUsed: 21000, numTxns: 1,
});
await mock.setValid(marker(VKEY, pv, PROOF), true);
await expect(v2.recordBlock(pv, PROOF)).to.be.revertedWithCustomError(
v2, "BlockHashUnavailable"
);
});
it("ACCEPTS a real in-window proof with the correct committed blockHash", async () => {
const bn = await ethers.provider.getBlockNumber();
const blk = await ethers.provider.getBlock(bn);
const parent = await ethers.provider.getBlock(bn - 1);
const pv = encodeV2({
chainId, blockNumber: bn,
parentHash: parent.hash, blockHash: blk.hash,
prevStateRoot: PREV_ROOT, postStateRoot: "0x" + "77".repeat(32), txRoot: TX_ROOT,
gasUsed: 21000, numTxns: 1,
});
await mock.setValid(marker(VKEY, pv, PROOF), true); // mines a block
await v2.recordBlock(pv, PROOF); // bn still in the 256 window here
expect(await v2.provenCount()).to.equal(1n);
const rec = await v2.getRecord(0);
expect(rec[0]).to.equal(BigInt(bn));
expect(rec[2]).to.equal(blk.hash); // blockHash stored (getRecord field #3)
});
it("REJECTS a wrong committed parentHash even when blockHash is right", async () => {
const bn = await ethers.provider.getBlockNumber();
const blk = await ethers.provider.getBlock(bn);
const pv = encodeV2({
chainId, blockNumber: bn,
parentHash: "0x" + "ee".repeat(32), // WRONG parent
blockHash: blk.hash,
prevStateRoot: PREV_ROOT, postStateRoot: "0x" + "77".repeat(32), txRoot: TX_ROOT,
gasUsed: 21000, numTxns: 1,
});
await mock.setValid(marker(VKEY, pv, PROOF), true);
await expect(v2.recordBlock(pv, PROOF)).to.be.revertedWithCustomError(
v2, "ParentHashMismatch"
);
});
});
describe("V2 batch continuity + tip binding", () => {
let b2;
beforeEach(async () => {
const F = await ethers.getContractFactory("AereEVMValidityBatchV2");
b2 = await F.deploy(await mock.getAddress(), VKEY);
await b2.waitForDeployment();
});
// Bind a batch's tip to a real recent block after mining a margin of blocks,
// so logical firstBlock..lastBlock ranges never go negative.
async function freshTip() {
await ethers.provider.send("hardhat_mine", ["0x14"]); // mine 20 blocks
const l = await ethers.provider.getBlockNumber();
const h = (await ethers.provider.getBlock(l)).hash;
return { l, h };
}
it("REJECTS a batch whose tip blockHash is wrong", async () => {
const { l } = await freshTip();
const pv = encodeBatchV2({
chainId, firstBlock: l - 2, lastBlock: l, numBlocks: 3,
firstParentHash: ZERO32, lastBlockHash: "0x" + "de".repeat(32), // wrong
prevStateRoot: PREV_ROOT, postStateRoot: "0x" + "a1".repeat(32),
totalGas: 63000, numTxns: 3,
});
await mock.setValid(marker(VKEY, pv, PROOF), true);
await expect(b2.recordBatch(pv, PROOF)).to.be.revertedWithCustomError(
b2, "BlockHashMismatch"
);
});
it("ACCEPTS a first batch, then ENFORCES continuity on the next", async () => {
const R1 = "0x" + "a1".repeat(32);
const R2 = "0x" + "b2".repeat(32);
// First batch [la-2 .. la] anchors the chain (no continuity needed yet).
const a = await freshTip();
const la = a.l;
const pvA = encodeBatchV2({
chainId, firstBlock: la - 2, lastBlock: la, numBlocks: 3,
firstParentHash: ZERO32, lastBlockHash: a.h,
prevStateRoot: PREV_ROOT, postStateRoot: R1, totalGas: 63000, numTxns: 3,
});
await mock.setValid(marker(VKEY, pvA, PROOF), true);
await b2.recordBatch(pvA, PROOF);
expect(await b2.provenCount()).to.equal(1n);
// Discontinuous firstBlock (gap) is rejected. lb is far above la+5.
const g = await freshTip();
const pvGap = encodeBatchV2({
chainId, firstBlock: la + 5, lastBlock: g.l, numBlocks: g.l - (la + 5) + 1,
firstParentHash: ZERO32, lastBlockHash: g.h,
prevStateRoot: R1, postStateRoot: R2, totalGas: 42000, numTxns: 2,
});
await mock.setValid(marker(VKEY, pvGap, PROOF), true);
await expect(b2.recordBatch(pvGap, PROOF)).to.be.revertedWithCustomError(
b2, "Discontinuous"
);
// Correct firstBlock but wrong prevStateRoot is rejected.
const br = await freshTip();
const pvBadRoot = encodeBatchV2({
chainId, firstBlock: la + 1, lastBlock: br.l, numBlocks: br.l - (la + 1) + 1,
firstParentHash: ZERO32, lastBlockHash: br.h,
prevStateRoot: "0x" + "99".repeat(32), postStateRoot: R2, totalGas: 42000, numTxns: 2,
});
await mock.setValid(marker(VKEY, pvBadRoot, PROOF), true);
await expect(b2.recordBatch(pvBadRoot, PROOF)).to.be.revertedWithCustomError(
b2, "Discontinuous"
);
// Contiguous + matching prevStateRoot is accepted.
const gd = await freshTip();
const pvGood = encodeBatchV2({
chainId, firstBlock: la + 1, lastBlock: gd.l, numBlocks: gd.l - (la + 1) + 1,
firstParentHash: ZERO32, lastBlockHash: gd.h,
prevStateRoot: R1, postStateRoot: R2, totalGas: 42000, numTxns: 2,
});
await mock.setValid(marker(VKEY, pvGood, PROOF), true);
await b2.recordBatch(pvGood, PROOF);
expect(await b2.provenCount()).to.equal(2n);
});
});
});