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.
404 lines
19 KiB
JavaScript
404 lines
19 KiB
JavaScript
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);
|
|
});
|
|
});
|
|
});
|