const { expect } = require("chai"); const { ethers, network } = require("hardhat"); // AereGovLock is the CUSTODY-LOCK vote source that replaces the flawed lazy // mirror (AereStakedGovVotes / gAERE). The whole point of this suite is to prove // that voting power CANNOT detach from the native AERE that backs it: // - votes are minted only when capital enters (payable lock), // - votes are burned only when capital leaves (gated withdraw), // - so at every snapshot totalSupply() == custody balance, and one unit of // capital can never yield more than one unit of votes. async function mine(n = 1) { for (let i = 0; i < n; i++) await network.provider.send("evm_mine"); } async function increaseTime(seconds) { await network.provider.send("evm_increaseTime", [seconds]); 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)); async function deployGovLock(minLockDuration = 0) { const F = await ethers.getContractFactory("AereGovLock"); const c = await F.deploy(minLockDuration); await c.waitForDeployment(); return c; } describe("AereGovLock (custody-lock vote source)", function () { // ------------------------------------------------------------------------- // 1. Normal lock / vote / withdraw lifecycle. // ------------------------------------------------------------------------- describe("normal lock / vote / withdraw lifecycle", function () { it("locks native AERE, mints 1:1 votes, delegates, and withdraws after unlock", async function () { const [user] = await ethers.getSigners(); const gl = await deployGovLock(0); const addr = await gl.getAddress(); const amount = E(1000); const unlock = (await now()) + 30 * DAY; // lock: capital IN, votes minted 1:1. await (await gl.lock(amount, unlock, { value: amount })).wait(); expect(await gl.balanceOf(user.address)).to.equal(amount); expect(await gl.totalSupply()).to.equal(amount); expect(await gl.totalLocked()).to.equal(amount); expect(await gl.lockedBalanceOf(user.address)).to.equal(amount); // Custody invariant: contract native balance == totalSupply. expect(await ethers.provider.getBalance(addr)).to.equal(amount); // Votes require delegation to become countable. expect(await gl.getVotes(user.address)).to.equal(0n); await (await gl.delegate(user.address)).wait(); expect(await gl.getVotes(user.address)).to.equal(amount); // Cannot withdraw before unlock. await expect(gl.withdraw()).to.be.revertedWith("govAERE: still locked"); // After unlock, withdraw burns votes and returns exact principal. await increaseTime(30 * DAY + 1); const balBefore = await ethers.provider.getBalance(user.address); const tx = await gl.withdraw(); const rc = await tx.wait(); const gas = rc.gasUsed * rc.gasPrice; const balAfter = await ethers.provider.getBalance(user.address); expect(balAfter).to.equal(balBefore + amount - gas); expect(await gl.balanceOf(user.address)).to.equal(0n); expect(await gl.getVotes(user.address)).to.equal(0n); expect(await gl.totalSupply()).to.equal(0n); expect(await ethers.provider.getBalance(addr)).to.equal(0n); }); it("rejects amount == 0 and value != amount", async function () { const gl = await deployGovLock(0); const unlock = (await now()) + 10 * DAY; await expect(gl.lock(0, unlock, { value: 0 })).to.be.revertedWith("govAERE: zero amount"); await expect(gl.lock(E(5), unlock, { value: E(4) })).to.be.revertedWith("govAERE: value != amount"); }); it("additive lock increases amount and can only push unlock forward", async function () { const [user] = await ethers.getSigners(); const gl = await deployGovLock(0); const t0 = await now(); await (await gl.lock(E(100), t0 + 20 * DAY, { value: E(100) })).wait(); // adding more with an EARLIER unlock is rejected (cannot shorten). await expect( gl.lock(E(50), t0 + 10 * DAY, { value: E(50) }) ).to.be.revertedWith("govAERE: cannot shorten lock"); // adding more with a later unlock works and accumulates. await (await gl.lock(E(50), t0 + 40 * DAY, { value: E(50) })).wait(); expect(await gl.lockedBalanceOf(user.address)).to.equal(E(150)); expect(await gl.totalSupply()).to.equal(E(150)); const [amt, un] = await gl.locks(user.address); expect(amt).to.equal(E(150)); expect(un).to.equal(BigInt(t0 + 40 * DAY)); }); it("extendLock moves unlock forward without changing votes", async function () { const [user] = await ethers.getSigners(); const gl = await deployGovLock(0); const t0 = await now(); await (await gl.lock(E(100), t0 + 20 * DAY, { value: E(100) })).wait(); await (await gl.extendLock(t0 + 60 * DAY)).wait(); const [amt, un] = await gl.locks(user.address); expect(amt).to.equal(E(100)); expect(un).to.equal(BigInt(t0 + 60 * DAY)); // cannot move it backward. await expect(gl.extendLock(t0 + 30 * DAY)).to.be.revertedWith("govAERE: not later"); }); }); // ------------------------------------------------------------------------- // 2. Optional minimum lock duration. // ------------------------------------------------------------------------- describe("minimum lock duration", function () { it("enforces min duration and rejects above max", async function () { const gl = await deployGovLock(7 * DAY); // below min -> revert. await expect( gl.lock(E(1), (await now()) + 3 * DAY, { value: E(1) }) ).to.be.revertedWith("govAERE: below min duration"); // above max -> revert. await expect( gl.lock(E(1), (await now()) + 2000 * DAY, { value: E(1) }) ).to.be.revertedWith("govAERE: above max duration"); // comfortably past min -> ok. await (await gl.lock(E(1), (await now()) + 8 * DAY, { value: E(1) })).wait(); expect(await gl.totalSupply()).to.equal(E(1)); }); it("constructor rejects min > MAX_LOCK_DURATION", async function () { const F = await ethers.getContractFactory("AereGovLock"); await expect(F.deploy(2000 * DAY)).to.be.revertedWith("govAERE: min > max"); }); }); // ------------------------------------------------------------------------- // 3. Non-transferability (transfer / transferFrom / approve revert), while // delegation still works. // ------------------------------------------------------------------------- describe("non-transferability", function () { it("reverts transfer, transferFrom, approve, increase/decreaseAllowance", async function () { const [user, other] = await ethers.getSigners(); const gl = await deployGovLock(0); await (await gl.lock(E(10), (await now()) + 10 * DAY, { value: E(10) })).wait(); await expect(gl.transfer(other.address, E(1))).to.be.revertedWith("govAERE: non-transferable"); await expect( gl.transferFrom(user.address, other.address, E(1)) ).to.be.revertedWith("govAERE: non-transferable"); await expect(gl.approve(other.address, E(1))).to.be.revertedWith("govAERE: non-transferable"); await expect(gl.increaseAllowance(other.address, E(1))).to.be.revertedWith("govAERE: non-transferable"); await expect(gl.decreaseAllowance(other.address, E(1))).to.be.revertedWith("govAERE: non-transferable"); }); it("delegation moves voting units without moving custody balances", async function () { const [user, rep] = await ethers.getSigners(); const gl = await deployGovLock(0); await (await gl.lock(E(10), (await now()) + 10 * DAY, { value: E(10) })).wait(); // delegate to a representative: votes move, balance stays with the locker. await (await gl.delegate(rep.address)).wait(); expect(await gl.getVotes(rep.address)).to.equal(E(10)); expect(await gl.getVotes(user.address)).to.equal(0n); expect(await gl.balanceOf(user.address)).to.equal(E(10)); expect(await gl.balanceOf(rep.address)).to.equal(0n); // rep never received a transferable balance, only voting weight. }); }); // ------------------------------------------------------------------------- // 4. getPastVotes / getPastTotalSupply snapshot accuracy. // ------------------------------------------------------------------------- describe("snapshot accuracy", function () { it("getPastVotes and getPastTotalSupply are fixed at historical blocks", async function () { const [user] = await ethers.getSigners(); const gl = await deployGovLock(0); const t0 = await now(); await (await gl.lock(E(1000), t0 + 100 * DAY, { value: E(1000) })).wait(); await (await gl.delegate(user.address)).wait(); await mine(2); const blockA = await ethers.provider.getBlockNumber(); await mine(1); // move past blockA so it is queryable // add more capital, changing current votes but not the historical value. await (await gl.lock(E(500), t0 + 100 * DAY, { value: E(500) })).wait(); await mine(1); const blockB = await ethers.provider.getBlockNumber(); await mine(1); expect(await gl.getPastVotes(user.address, blockA)).to.equal(E(1000)); expect(await gl.getPastTotalSupply(blockA)).to.equal(E(1000)); expect(await gl.getPastVotes(user.address, blockB)).to.equal(E(1500)); expect(await gl.getPastTotalSupply(blockB)).to.equal(E(1500)); expect(await gl.getVotes(user.address)).to.equal(E(1500)); }); it("withdraw does not rewrite history: past snapshot still shows the old weight", async function () { const [user] = await ethers.getSigners(); const gl = await deployGovLock(0); const t0 = await now(); await (await gl.lock(E(1000), t0 + 5 * DAY, { value: E(1000) })).wait(); await (await gl.delegate(user.address)).wait(); await mine(2); const snap = await ethers.provider.getBlockNumber(); await mine(1); await increaseTime(5 * DAY + 1); await (await gl.withdraw()).wait(); await mine(1); // current weight is gone, but the historical snapshot is immutable. expect(await gl.getVotes(user.address)).to.equal(0n); expect(await gl.getPastVotes(user.address, snap)).to.equal(E(1000)); expect(await gl.getPastTotalSupply(snap)).to.equal(E(1000)); }); }); // ------------------------------------------------------------------------- // 5. CRITICAL: reproduce the old recycling / detach attack and prove it is // impossible here. One unit of capital yields at most one unit of votes at // any snapshot; capital cannot be recycled across sybils while keeping votes. // ------------------------------------------------------------------------- describe("recycling / detach attack is impossible", function () { it("cannot hold votes while the principal leaves, and cannot fund a sybil without burning votes", async function () { const [attacker, sybilA, sybilB] = await ethers.getSigners(); const gl = await deployGovLock(0); const addr = await gl.getAddress(); const CAPITAL = E(1000); // one single unit of capital, all the attacker has to work with // ---- Phase A: attacker locks the capital and delegates votes to sybilA. const t0 = await now(); await (await gl.connect(attacker).lock(CAPITAL, t0 + 7 * DAY, { value: CAPITAL })).wait(); await (await gl.connect(attacker).delegate(sybilA.address)).wait(); await mine(2); const blockA = await ethers.provider.getBlockNumber(); await mine(1); expect(await gl.getVotes(sybilA.address)).to.equal(CAPITAL); expect(await gl.totalSupply()).to.equal(CAPITAL); // Backing invariant: custody balance == totalSupply. expect(await ethers.provider.getBalance(addr)).to.equal(CAPITAL); // ---- Phase B: attacker tries to ALSO give sybilB 1000 votes from the SAME // capital. That needs the capital back to fund a second lock. It is // still locked, so withdraw reverts. The capital cannot detach. await expect(gl.connect(attacker).withdraw()).to.be.revertedWith("govAERE: still locked"); // There is no other exit: no earlyExit, no admin drain, no burn-less claim. // sybilB has zero votes; the recycle attempt is stuck. expect(await gl.getVotes(sybilB.address)).to.equal(0n); expect(await gl.totalSupply()).to.equal(CAPITAL); // still exactly one unit of votes // At snapshot blockA, total votes == one unit of capital, never N-fold. expect(await gl.getPastTotalSupply(blockA)).to.equal(CAPITAL); // ---- Phase C: the ONLY way to reclaim the capital is to wait and // withdraw, which BURNS the votes first. Delegation cannot save them: // burning the holder's balance removes weight from sybilA too. await increaseTime(7 * DAY + 1); await (await gl.connect(attacker).withdraw()).wait(); await mine(1); expect(await gl.getVotes(sybilA.address)).to.equal(0n); // votes burned with the withdraw expect(await gl.totalSupply()).to.equal(0n); expect(await ethers.provider.getBalance(addr)).to.equal(0n); // ---- Phase D: attacker re-locks the SAME capital into a second account to // try to hand sybilB votes. It works for sybilB now, but sybilA's // votes are already gone. The 1000 capital never backs more than 1000 // votes at any single moment. const t1 = await now(); // send the reclaimed capital to a second controlled account and lock from there await (await attacker.sendTransaction({ to: sybilB.address, value: CAPITAL })).wait(); await (await gl.connect(sybilB).lock(CAPITAL, t1 + 7 * DAY, { value: CAPITAL })).wait(); await (await gl.connect(sybilB).delegate(sybilB.address)).wait(); await mine(2); const blockD = await ethers.provider.getBlockNumber(); await mine(1); expect(await gl.getVotes(sybilB.address)).to.equal(CAPITAL); expect(await gl.getVotes(sybilA.address)).to.equal(0n); // NOT simultaneously 1000 expect(await gl.totalSupply()).to.equal(CAPITAL); expect(await ethers.provider.getBalance(addr)).to.equal(CAPITAL); // The decisive proof: at EVERY historical snapshot, total voting supply // equalled the custody balance at that time, i.e. at most one unit of votes // per one unit of capital. Recycling produced no extra power. expect(await gl.getPastTotalSupply(blockA)).to.equal(CAPITAL); // phase A expect(await gl.getPastTotalSupply(blockD)).to.equal(CAPITAL); // phase D // sum of the two sybils' weight at blockD is exactly one unit, not two. const wA = await gl.getPastVotes(sybilA.address, blockD); const wB = await gl.getPastVotes(sybilB.address, blockD); expect(wA + wB).to.equal(CAPITAL); }); it("invariant: totalSupply always equals the contract's custody balance", async function () { const [a, b, c] = await ethers.getSigners(); const gl = await deployGovLock(0); const addr = await gl.getAddress(); const t0 = await now(); const check = async () => expect(await gl.totalSupply()).to.equal(await ethers.provider.getBalance(addr)); await (await gl.connect(a).lock(E(100), t0 + 3 * DAY, { value: E(100) })).wait(); await check(); await (await gl.connect(b).lock(E(250), t0 + 3 * DAY, { value: E(250) })).wait(); await check(); await (await gl.connect(c).lock(E(75), t0 + 3 * DAY, { value: E(75) })).wait(); await check(); await increaseTime(3 * DAY + 1); await (await gl.connect(b).withdraw()).wait(); await check(); await (await gl.connect(a).withdraw()).wait(); await check(); await (await gl.connect(c).withdraw()).wait(); await check(); expect(await gl.totalSupply()).to.equal(0n); }); }); // ------------------------------------------------------------------------- // 6. Drop-in compatibility with the existing OZ Governor + Timelock. // ------------------------------------------------------------------------- describe("drop-in AereGovernor + TimelockController compatibility", function () { it("propose -> vote (govAERE weight) -> queue -> execute flips an owned target", async function () { const [deployer] = await ethers.getSigners(); const gl = await deployGovLock(0); // deployer locks capital and self-delegates to obtain real voting weight. // (test accounts hold 10,000 ETH each; lock a chunk and keep gas headroom.) const LOCKED = E(5000); await (await gl.lock(LOCKED, (await now()) + 60 * DAY, { value: LOCKED })).wait(); await (await gl.delegate(deployer.address)).wait(); const TL = await ethers.getContractFactory("TimelockController"); const timelock = await TL.deploy(0, [], [], deployer.address); await timelock.waitForDeployment(); const Gov = await ethers.getContractFactory("AereGovernor"); const gov = await Gov.deploy(await gl.getAddress(), await timelock.getAddress(), 1, 5, 0, 4); 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(); await mine(2); const targets = [await target.getAddress()]; const values = [0n]; const calldatas = [target.interface.encodeFunctionData("setValue", [42])]; const description = "govlock setValue(42)"; const descHash = ethers.id(description); const proposalId = await gov.hashProposal(targets, values, calldatas, descHash); await (await gov.propose(targets, values, calldatas, description)).wait(); const snapshot = await gov.proposalSnapshot(proposalId); const deadline = await gov.proposalDeadline(proposalId); const cur = await ethers.provider.getBlockNumber(); await mine(Number(snapshot) - cur + 1); expect(Number(await gov.state(proposalId))).to.equal(1); // Active // quorum is a real fraction of real locked capital (meaningful denominator). expect(await gov.quorum(snapshot)).to.equal((LOCKED * 4n) / 100n); await (await gov.castVote(proposalId, 1)).wait(); const cur2 = await ethers.provider.getBlockNumber(); await mine(Number(deadline) - cur2 + 1); expect(Number(await gov.state(proposalId))).to.equal(4); // 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(7); // Executed expect(await target.value()).to.equal(42n); }); }); });