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); }); });