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

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