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

348 lines
18 KiB
JavaScript

const { expect } = require("chai");
const { ethers, network } = require("hardhat");
// ---------------------------------------------------------------------------
// FINDING 1 — governance quorum inflation.
//
// The generic AereGovernor uses GovernorVotesQuorumFraction, so quorum for a
// proposal is token.getPastTotalSupply(snapshot) * pct / 100 with NO floor, NO
// cap and NO time-averaging. AereGovLock.lock() permissionlessly mints govAERE
// 1:1 from the abundant native token, so an attacker can lock a large amount
// just before a proposal's snapshot, stay SILENT (never vote), and permanently
// fix an unreachable quorum. Honest holders voting 100% For then cannot reach
// quorum and the proposal is Defeated. The attacker cannot force-pass anything
// (principal is returned after the lock), but can veto every proposal.
//
// This suite reproduces the EXACT attack against the current AereGovernor and
// proves the corrected governor (AereGovernorV2, with an absolute quorum FLOOR
// and CAP) defeats it, while a genuinely low-turnout proposal still correctly
// fails quorum (no over-correction).
// ---------------------------------------------------------------------------
async function mine(n = 1) {
for (let i = 0; i < n; i++) await network.provider.send("evm_mine");
}
async function now() {
const b = await ethers.provider.getBlock("latest");
return b.timestamp;
}
const DAY = 24 * 60 * 60;
const E = (n) => ethers.parseEther(String(n));
// Give a signer enough native AERE to fund a large lock (default test accounts
// only hold 10,000, far below the whale's stake). This mirrors chain 2800's
// abundant native supply that makes the inflation attack cheap.
async function fund(signer, amount) {
const bal = amount + E(1000); // stake plus gas headroom
await network.provider.send("hardhat_setBalance", [signer.address, "0x" + bal.toString(16)]);
}
// Proposal states (OZ Governor enum).
const STATE = { Pending: 0, Active: 1, Canceled: 2, Defeated: 3, Succeeded: 4, Queued: 5, Expired: 6, Executed: 7 };
// Small windows so a whale can lock inside the voting-delay window, right before
// the snapshot block, exactly as the attack describes.
const VOTING_DELAY = 4; // blocks
const VOTING_PERIOD = 12; // blocks
const QUORUM_PCT = 10; // 10% of past total supply
// Absolute band for the corrected governor.
const QUORUM_FLOOR = E(100000); // 100k govAERE minimum quorum
const QUORUM_CAP = E(500000); // 500k govAERE secondary ceiling
// Denominator lookback (blocks): must cover the voting-delay window plus a margin
// so a lock placed after (or front-running) the proposal cannot inflate quorum.
const QUORUM_LOOKBACK = VOTING_DELAY + 2; // 6 blocks
// Attack sizing.
const HONEST = E(1000000); // honest holders lock 1,000,000 and vote 100% For
const WHALE = E(100000000); // whale silently locks 100,000,000 just before snapshot
async function deployGovLock(minLockDuration = 0) {
const F = await ethers.getContractFactory("AereGovLock");
const c = await F.deploy(minLockDuration);
await c.waitForDeployment();
return c;
}
// Deploy a governor (by contract name) wired to the given vote source, plus a
// throwaway timelock (0 delay) and an owned target.
async function deployGovStack(governorName, gl, deployer, extraArgs = []) {
const TL = await ethers.getContractFactory("TimelockController");
const timelock = await TL.deploy(0, [], [], deployer.address);
await timelock.waitForDeployment();
const Gov = await ethers.getContractFactory(governorName);
const gov = await Gov.deploy(
await gl.getAddress(),
await timelock.getAddress(),
VOTING_DELAY,
VOTING_PERIOD,
0, // proposalThreshold: 0 so an honest holder can always propose
QUORUM_PCT,
...extraArgs
);
await gov.waitForDeployment();
const PROPOSER = await timelock.PROPOSER_ROLE();
const EXECUTOR = await timelock.EXECUTOR_ROLE();
const CANCELLER = await timelock.CANCELLER_ROLE();
await (await timelock.grantRole(PROPOSER, await gov.getAddress())).wait();
await (await timelock.grantRole(EXECUTOR, await gov.getAddress())).wait();
await (await timelock.grantRole(CANCELLER, await gov.getAddress())).wait();
const Target = await ethers.getContractFactory("GovSelfTestTarget");
const target = await Target.deploy();
await target.waitForDeployment();
await (await target.transferOwnership(await timelock.getAddress())).wait();
return { gov, timelock, target };
}
// Drive one proposal. The honest signer (already locked + delegated) proposes.
// If whaleAmount > 0, the whale locks that amount SILENTLY inside the voting-
// delay window, before the snapshot block. The honest signer then votes For.
// Returns the final state and the quorum that applied at the snapshot.
async function runProposal(gl, gov, target, honest, whale, whaleAmount) {
const targets = [await target.getAddress()];
const values = [0n];
const calldatas = [target.interface.encodeFunctionData("setValue", [42])];
const description = `proposal ${Math.random()}`;
const descHash = ethers.id(description);
const proposalId = await gov.hashProposal(targets, values, calldatas, descHash);
await (await gov.connect(honest).propose(targets, values, calldatas, description)).wait();
const snapshot = await gov.proposalSnapshot(proposalId);
const deadline = await gov.proposalDeadline(proposalId);
// Whale inflates the total supply SILENTLY, before the snapshot block.
if (whaleAmount > 0n) {
const cur = await ethers.provider.getBlockNumber();
expect(cur, "whale must lock before snapshot").to.be.lessThan(Number(snapshot));
await (await gl.connect(whale).lock(whaleAmount, (await now()) + 30 * DAY, { value: whaleAmount })).wait();
await (await gl.connect(whale).delegate(whale.address)).wait(); // real checkpointed supply, but never votes
}
const cur = await ethers.provider.getBlockNumber();
await mine(Number(snapshot) - cur + 1);
expect(Number(await gov.state(proposalId))).to.equal(STATE.Active);
const quorumAtSnapshot = await gov.quorum(snapshot);
await (await gov.connect(honest).castVote(proposalId, 1)).wait(); // 100% For
const cur2 = await ethers.provider.getBlockNumber();
await mine(Number(deadline) - cur2 + 1);
return { state: Number(await gov.state(proposalId)), quorumAtSnapshot, proposalId };
}
describe("FINDING 1 — governance quorum inflation", function () {
// -------------------------------------------------------------------------
// 1. Reproduce the attack against the CURRENT AereGovernor: a silent whale
// lock before the snapshot fixes an unreachable quorum, so a unanimous
// honest proposal is Defeated.
// -------------------------------------------------------------------------
it("current AereGovernor: silent whale lock before snapshot blocks a unanimous honest proposal", async function () {
const [deployer, honest, whale] = await ethers.getSigners();
const gl = await deployGovLock(0);
await fund(honest, HONEST); await fund(whale, WHALE);
// Honest holder locks and self-delegates BEFORE proposing.
await (await gl.connect(honest).lock(HONEST, (await now()) + 30 * DAY, { value: HONEST })).wait();
await (await gl.connect(honest).delegate(honest.address)).wait();
await mine(2);
const { gov, target } = await deployGovStack("AereGovernor", gl, deployer);
const { state, quorumAtSnapshot } = await runProposal(gl, gov, target, honest, whale, WHALE);
// The whale inflated quorum to 10% of (HONEST + WHALE); honest For == HONEST
// is far below it, so the proposal is Defeated even at 100% honest turnout.
expect(quorumAtSnapshot).to.equal(((HONEST + WHALE) * BigInt(QUORUM_PCT)) / 100n);
expect(quorumAtSnapshot).to.be.greaterThan(HONEST);
expect(state).to.equal(STATE.Defeated); // attack succeeds: honest proposal vetoed
});
// -------------------------------------------------------------------------
// 2. WITH THE FIX (AereGovernorV2): the SAME attack fails. The quorum
// denominator is read at a pre-proposal timepoint, so the whale's reactive
// lock is not counted at all. Quorum stays a fraction of the honest
// pre-proposal supply, below honest turnout, and the proposal Succeeds.
// -------------------------------------------------------------------------
it("AereGovernorV2: pre-proposal denominator defeats the quorum-inflation attack, honest proposal succeeds", async function () {
const [deployer, honest, whale] = await ethers.getSigners();
const gl = await deployGovLock(0);
await fund(honest, HONEST); await fund(whale, WHALE);
await (await gl.connect(honest).lock(HONEST, (await now()) + 30 * DAY, { value: HONEST })).wait();
await (await gl.connect(honest).delegate(honest.address)).wait();
await mine(2);
const { gov, target } = await deployGovStack("AereGovernorV2", gl, deployer, [QUORUM_FLOOR, QUORUM_CAP, QUORUM_LOOKBACK]);
const { state, quorumAtSnapshot } = await runProposal(gl, gov, target, honest, whale, WHALE);
// The whale's 100,000,000 lock lands after proposal creation, so it is outside
// the measured (pre-proposal) supply of 1,000,000. Quorum is 10% of that
// 1,000,000 == 100,000 (which coincides with the floor here), far below honest
// For (1,000,000). Quorum reached -> Succeeded.
expect(quorumAtSnapshot).to.equal((HONEST * BigInt(QUORUM_PCT)) / 100n); // 100,000
expect(quorumAtSnapshot).to.be.lessThan(HONEST);
expect(state).to.equal(STATE.Succeeded); // attack closed
});
// -------------------------------------------------------------------------
// 3. WITH THE FIX: no over-correction. A genuinely low-turnout proposal (For
// below the absolute floor) still correctly fails quorum.
// -------------------------------------------------------------------------
it("AereGovernorV2: floor still fails a genuinely low-turnout proposal", async function () {
const [deployer, honest, whale] = await ethers.getSigners();
const gl = await deployGovLock(0);
// Total locked supply is tiny (below the floor), and it is 100% For, yet the
// absolute amount is still below the quorum floor, so it must fail.
const TINY = E(60000); // < QUORUM_FLOOR (100k)
await fund(honest, TINY);
await (await gl.connect(honest).lock(TINY, (await now()) + 30 * DAY, { value: TINY })).wait();
await (await gl.connect(honest).delegate(honest.address)).wait();
await mine(2);
const { gov, target } = await deployGovStack("AereGovernorV2", gl, deployer, [QUORUM_FLOOR, QUORUM_CAP, QUORUM_LOOKBACK]);
const { state, quorumAtSnapshot } = await runProposal(gl, gov, target, honest, whale, 0n /* no whale */);
// Fractional (10% of 60k = 6k) is below the floor, so quorum == floor (100k),
// and honest For (60k) is below it. Correctly Defeated: the floor is a real bar.
expect(quorumAtSnapshot).to.equal(QUORUM_FLOOR);
expect(state).to.equal(STATE.Defeated);
});
// -------------------------------------------------------------------------
// 3b. RESIDUAL ATTACK (iteration-1 gap). The cap-only defense only works if the
// cap is set at or below expected honest turnout. With a LOOSE cap (the real
// deploy stages CAP = 50,000,000, far above realistic turnout) a silent whale
// that locks enough govAERE AFTER the proposal is created can still push the
// fractional quorum above the cap while the cap itself sits above honest For,
// re-fixing an unreachable bar. A robust governor must defeat this WITHOUT
// depending on a perfectly chosen cap.
//
// Setup: floor 100k, LOOSE cap 50,000,000. Honest locks 1,000,000 and votes
// 100% For. Whale silently locks 1,000,000,000 inside the voting-delay window.
// - fractional at snapshot = 10% of ~1.001B ≈ 100,100,000, clamped to the
// 50,000,000 cap. 50,000,000 is still >> honest For (1,000,000), so a
// cap-only governor Defeats the unanimous honest proposal (attack works).
// - a robust governor measures the quorum denominator at a pre-proposal
// timepoint the late lock cannot inflate, so quorum stays 10% of the
// honest 1,000,000 = 100,000 (== floor here), below honest For, and the
// proposal Succeeds.
// -------------------------------------------------------------------------
it("AereGovernorV2: robust denominator defeats a reactive whale lock even with a LOOSE cap", async function () {
const [deployer, honest, whale] = await ethers.getSigners();
const gl = await deployGovLock(0);
const BIG_WHALE = E(1000000000); // 1,000,000,000 locked silently after creation
const LOOSE_CAP = E(50000000); // 50,000,000: the staged real-deploy cap, far above turnout
await fund(honest, HONEST); await fund(whale, BIG_WHALE);
await (await gl.connect(honest).lock(HONEST, (await now()) + 30 * DAY, { value: HONEST })).wait();
await (await gl.connect(honest).delegate(honest.address)).wait();
await mine(2);
const { gov, target } = await deployGovStack("AereGovernorV2", gl, deployer, [QUORUM_FLOOR, LOOSE_CAP, QUORUM_LOOKBACK]);
const { state, quorumAtSnapshot } = await runProposal(gl, gov, target, honest, whale, BIG_WHALE);
// A robust governor keeps quorum at 10% of the PRE-proposal honest supply
// (100,000 == floor), which is below honest For, so the proposal Succeeds.
// A cap-only governor would clamp to LOOSE_CAP (50,000,000) > honest For and Defeat it.
expect(quorumAtSnapshot).to.equal(QUORUM_FLOOR); // 10% of 1,000,000 == floor 100,000
expect(quorumAtSnapshot).to.be.lessThan(HONEST);
expect(state).to.equal(STATE.Succeeded); // residual gap closed
});
// -------------------------------------------------------------------------
// 4. WITH THE FIX: a healthy proposal inside the band still passes and
// executes end to end through the timelock (fix does not break normal use).
// -------------------------------------------------------------------------
it("AereGovernorV2: healthy proposal still passes and executes through the timelock", async function () {
const [deployer, honest] = await ethers.getSigners();
const gl = await deployGovLock(0);
// 3,000,000 locked -> fractional 300k, inside [100k, 500k] -> quorum 300k.
const LOCKED = E(3000000);
await fund(honest, LOCKED);
await (await gl.connect(honest).lock(LOCKED, (await now()) + 30 * DAY, { value: LOCKED })).wait();
await (await gl.connect(honest).delegate(honest.address)).wait();
await mine(2);
const { gov, target } = await deployGovStack("AereGovernorV2", gl, deployer, [QUORUM_FLOOR, QUORUM_CAP, QUORUM_LOOKBACK]);
const targets = [await target.getAddress()];
const values = [0n];
const calldatas = [target.interface.encodeFunctionData("setValue", [42])];
const description = "healthy setValue(42)";
const descHash = ethers.id(description);
const proposalId = await gov.hashProposal(targets, values, calldatas, descHash);
await (await gov.connect(honest).propose(targets, values, calldatas, description)).wait();
const snapshot = await gov.proposalSnapshot(proposalId);
const deadline = await gov.proposalDeadline(proposalId);
let cur = await ethers.provider.getBlockNumber();
await mine(Number(snapshot) - cur + 1);
// Query quorum only after the snapshot block exists (getPastTotalSupply
// reverts on a future lookup). 300k is inside [100k, 500k].
expect(await gov.quorum(snapshot)).to.equal((LOCKED * BigInt(QUORUM_PCT)) / 100n);
await (await gov.connect(honest).castVote(proposalId, 1)).wait();
cur = await ethers.provider.getBlockNumber();
await mine(Number(deadline) - cur + 1);
expect(Number(await gov.state(proposalId))).to.equal(STATE.Succeeded);
await (await gov.queue(targets, values, calldatas, descHash)).wait();
await mine(1);
await (await gov.execute(targets, values, calldatas, descHash)).wait();
expect(Number(await gov.state(proposalId))).to.equal(STATE.Executed);
expect(await target.value()).to.equal(42n);
});
// -------------------------------------------------------------------------
// 5. EARLY-PROPOSAL CLAMP: when the configured lookback reaches before genesis
// (timepoint <= lookback), the denominator point is clamped to 0 so the
// lookup never underflows or reverts, and the floor governs. quorum() must
// still return exactly the floor (never a silent zero) and must not revert.
// -------------------------------------------------------------------------
it("AereGovernorV2: an absurdly large lookback clamps to block 0 and returns the floor without reverting", async function () {
const [deployer, honest] = await ethers.getSigners();
const gl = await deployGovLock(0);
const LOCKED = E(3000000); // 10% == 300k, inside the band, but the clamp forces floor
await fund(honest, LOCKED);
await (await gl.connect(honest).lock(LOCKED, (await now()) + 30 * DAY, { value: LOCKED })).wait();
await (await gl.connect(honest).delegate(honest.address)).wait();
await mine(2);
// Lookback far larger than the current block height forces lookbackPoint -> 0,
// where getPastTotalSupply reads the genesis supply (0). Fractional is then 0,
// so the floor must govern.
const HUGE_LOOKBACK = 1000000000; // >> current block number
const { gov, target } = await deployGovStack(
"AereGovernorV2", gl, deployer, [QUORUM_FLOOR, QUORUM_CAP, HUGE_LOOKBACK]
);
const targets = [await target.getAddress()];
const values = [0n];
const calldatas = [target.interface.encodeFunctionData("setValue", [42])];
const description = "early-proposal clamp";
const descHash = ethers.id(description);
const proposalId = await gov.hashProposal(targets, values, calldatas, descHash);
await (await gov.connect(honest).propose(targets, values, calldatas, description)).wait();
const snapshot = await gov.proposalSnapshot(proposalId);
const cur = await ethers.provider.getBlockNumber();
await mine(Number(snapshot) - cur + 1);
// Does not revert; supply-at-0 is 0, so quorum falls back to the floor.
expect(await gov.quorum(snapshot)).to.equal(QUORUM_FLOOR);
});
});