aere-contracts/test/audit-fix-v2-staking-2026-07.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

345 lines
18 KiB
JavaScript

// AUDIT FIX VERIFICATION — AereStakingV2 — 2026-07-10
//
// Proves the five confirmed AereStaking (V1) bugs are FIXED in AereStakingV2.
// For each finding: V1 REPRODUCES the bug, V2 BLOCKS/CORRECTS it. Runs fully local
// against the REAL contract sources (same source that gets deployed to chain 2800).
//
// F2 reward drain on top-up — V2 settles before top-up (0 retroactive reward)
// F3 locked validator self-stake — V2 deregisterValidator + claimUnbonded recovers it
// F4 re-register bricks unbond — V2 preserves totalDelegated + no validatorList dup
// F5 APY ~5x underpay — V2 timestamp accrual hits 8%; rewardRateBps settable
// LOW commission silently kept — V2 credits + pays validator commission
//
// AereStakingV2 constructor takes initialOwner; tests deploy with owner = signer[0]
// so owner-only slash / setRewardRateBps can be exercised.
const { expect } = require("chai");
const { ethers } = require("hardhat");
const ONE = 10n ** 18n;
const BPS_DENOM = 10000n;
const YEAR = 31536000n; // 365 days, V2 timestamp accrual base
const V1_BLOCKS_PER_YEAR = 315360000n; // V1's hardcoded (0.1s-block) constant
// V2 gross reward for `amount` over `secs` seconds at `rateBps` (timestamp accrual)
function grossV2(amount, secs, rateBps = 800n) {
return (amount * rateBps * secs) / (BPS_DENOM * YEAR);
}
async function deployV1() {
const S = await ethers.getContractFactory("AereStaking");
const st = await S.deploy();
await st.waitForDeployment();
return st;
}
async function deployV2(ownerAddr) {
const S = await ethers.getContractFactory("AereStakingV2");
const st = await S.deploy(ownerAddr);
await st.waitForDeployment();
return st;
}
async function ts() {
return BigInt((await ethers.provider.getBlock("latest")).timestamp);
}
describe("AereStakingV2 — audit fixes (V1 reproduces, V2 corrects)", function () {
this.timeout(300000);
// ───────────────────────── F2 ─────────────────────────
describe("F2 top-up retroactive reward drain", function () {
it("V1 REPRODUCES: top-up makes the whole balance earn over the full past window", async () => {
const [v, d] = await ethers.getSigners();
const st = await deployV1();
await st.connect(v).registerValidator(0, { value: 500n * ONE });
const X = 100n * ONE;
await st.connect(d).delegate(v.address, { value: X });
const firstClaimBlock = (await st.delegations(v.address, d.address)).lastClaimBlock;
await ethers.provider.send("hardhat_mine", ["0x3E8"]); // 1000 blocks
const Y = 900n * ONE;
await st.connect(d).delegate(v.address, { value: Y }); // no settle in V1
const del1 = await st.delegations(v.address, d.address);
expect(del1.lastClaimBlock).to.equal(firstClaimBlock); // NOT reset (the bug)
expect(del1.amount).to.equal(X + Y);
const cur = BigInt(await ethers.provider.getBlockNumber());
const elapsed = cur - firstClaimBlock;
const buggy = ((X + Y) * 800n * elapsed) / (V1_BLOCKS_PER_YEAR * 10000n);
const fairOnX = (X * 800n * elapsed) / (V1_BLOCKS_PER_YEAR * 10000n);
const pAfter = await st.pendingRewards(v.address, d.address);
expect(pAfter).to.equal(buggy); // pays on X+Y over the whole window
expect(pAfter).to.be.gt(fairOnX * 9n); // ~10x the fair value (Y=9X earns full retro)
});
it("V2 CORRECTS: top-up settles first, so the added stake earns 0 retroactive reward", async () => {
const [owner, v, d] = await ethers.getSigners();
const st = await deployV2(owner.address);
await st.connect(v).registerValidator(0, { value: 500n * ONE });
const X = 100n * ONE;
await st.connect(d).delegate(v.address, { value: X });
const L0 = (await st.delegations(v.address, d.address)).lastClaimTime;
// Advance EXACTLY 1000s, then top up in that same block so the read (a view at
// the top-up block) sees 0 elapsed since the settle.
const T = 1000n;
await ethers.provider.send("evm_setNextBlockTimestamp", [Number(L0 + T)]);
const Y = 900n * ONE;
await st.connect(d).delegate(v.address, { value: Y }); // settles X over 1000s, then adds Y
const del1 = await st.delegations(v.address, d.address);
expect(del1.lastClaimTime).to.equal(L0 + T); // anchor advanced (the fix)
expect(del1.amount).to.equal(X + Y);
const expectedNet = grossV2(X, T); // only X earned, only for 1000s
const buggyWouldBe = grossV2(X + Y, T); // what V1 would have paid
const pAfter = await st.pendingRewards(v.address, d.address);
expect(pAfter).to.equal(expectedNet); // exact: Y contributes nothing
expect(pAfter * 9n).to.be.lt(buggyWouldBe * 2n); // pAfter << buggy (Y did not earn retro)
// rewardAccrued holds exactly the settled net for X.
expect(del1.rewardAccrued).to.equal(expectedNet);
});
});
// ───────────────────────── F3 ─────────────────────────
describe("F3 validator self-stake recovery", function () {
it("V1 REPRODUCES: no path returns self-stake; it is locked forever", async () => {
const [v] = await ethers.getSigners();
const st = await deployV1();
await st.connect(v).registerValidator(0, { value: 500n * ONE });
expect(await ethers.provider.getBalance(await st.getAddress())).to.equal(500n * ONE);
const names = st.interface.fragments.filter((f) => f.type === "function").map((f) => f.name);
for (const n of ["deregisterValidator", "withdrawSelfStake", "exitValidator"]) {
expect(names).to.not.include(n);
}
await expect(st.connect(v).unbond(v.address, 500n * ONE)).to.be.revertedWith(
"AereStaking: insufficient delegation"
);
await expect(st.connect(v).claimUnbonded()).to.be.revertedWith("AereStaking: nothing to claim");
expect(await ethers.provider.getBalance(await st.getAddress())).to.equal(500n * ONE); // stuck
});
it("V2 CORRECTS: deregisterValidator + claimUnbonded returns the self-stake after the delay", async () => {
const [owner, v] = await ethers.getSigners();
const st = await deployV2(owner.address);
const addr = await st.getAddress();
await st.connect(v).registerValidator(0, { value: 500n * ONE });
expect(await st.totalStaked()).to.equal(500n * ONE);
await st.connect(v).deregisterValidator();
expect((await st.validators(v.address)).selfStake).to.equal(0n);
expect((await st.validators(v.address)).active).to.equal(false);
expect(await st.totalStaked()).to.equal(0n);
expect(await st.unbondQueueLength(v.address)).to.equal(1n);
// Not matured yet.
await expect(st.connect(v).claimUnbonded()).to.be.revertedWith("AereStakingV2: nothing to claim");
// Mature the 7-day unbonding.
await ethers.provider.send("evm_increaseTime", [7 * 24 * 3600 + 1]);
await ethers.provider.send("evm_mine", []);
const before = await ethers.provider.getBalance(v.address);
const rc = await (await st.connect(v).claimUnbonded()).wait();
const gas = rc.gasUsed * rc.gasPrice;
const after = await ethers.provider.getBalance(v.address);
expect(after + gas - before).to.equal(500n * ONE); // full self-stake recovered
expect(await ethers.provider.getBalance(addr)).to.equal(0n);
});
it("V2 CORRECTS: a slashed-out validator recovers only the POST-slash self-stake; slashed value stays in the pool (accounted, not orphaned)", async () => {
const [owner, v] = await ethers.getSigners();
const st = await deployV2(owner.address);
const addr = await st.getAddress();
await st.connect(v).registerValidator(0, { value: 500n * ONE });
await st.connect(owner).slash(v.address, "double-sign"); // 5% -> 475 left, deactivated
const residual = (await st.validators(v.address)).selfStake;
expect(residual).to.equal(475n * ONE);
expect((await st.validators(v.address)).active).to.equal(false);
// deregister works even though already deactivated.
await st.connect(v).deregisterValidator();
await ethers.provider.send("evm_increaseTime", [7 * 24 * 3600 + 1]);
await ethers.provider.send("evm_mine", []);
const before = await ethers.provider.getBalance(v.address);
const rc = await (await st.connect(v).claimUnbonded()).wait();
const gas = rc.gasUsed * rc.gasPrice;
const after = await ethers.provider.getBalance(v.address);
expect(after + gas - before).to.equal(475n * ONE); // only post-slash amount back
// The slashed 25 AERE remains in the pool (surplus), not orphaned to the validator.
expect(await ethers.provider.getBalance(addr)).to.equal(25n * ONE);
expect(await st.totalStaked()).to.equal(0n);
});
});
// ───────────────────────── F4 ─────────────────────────
describe("F4 re-registration after slash", function () {
it("V1 REPRODUCES: re-register zeroes totalDelegated (unbond underflow) and duplicates validatorList", async () => {
const [owner, v, d] = await ethers.getSigners();
const st = await deployV1();
await st.connect(v).registerValidator(0, { value: 500n * ONE });
const D = 50n * ONE;
await st.connect(d).delegate(v.address, { value: D });
expect((await st.validators(v.address)).totalDelegated).to.equal(D);
expect(await st.validatorCount()).to.equal(1n);
await st.connect(owner).slash(v.address, "double-sign");
expect((await st.validators(v.address)).active).to.equal(false);
await st.connect(v).registerValidator(0, { value: 500n * ONE }); // overwrites struct
expect((await st.validators(v.address)).totalDelegated).to.equal(0n); // BUG: zeroed
expect(await st.validatorCount()).to.equal(2n); // BUG: duplicate
expect((await st.delegations(v.address, d.address)).amount).to.equal(D);
await expect(st.connect(d).unbond(v.address, D)).to.be.revertedWithPanic(0x11);
});
it("V2 CORRECTS: re-register preserves totalDelegated + delegations, no duplicate; delegator can still unbond", async () => {
const [owner, v, d] = await ethers.getSigners();
const st = await deployV2(owner.address);
await st.connect(v).registerValidator(0, { value: 500n * ONE });
const D = 50n * ONE;
await st.connect(d).delegate(v.address, { value: D });
expect((await st.validators(v.address)).totalDelegated).to.equal(D);
expect(await st.validatorCount()).to.equal(1n);
await st.connect(owner).slash(v.address, "double-sign");
expect((await st.validators(v.address)).active).to.equal(false);
await st.connect(v).registerValidator(0, { value: 500n * ONE }); // re-activate
expect((await st.validators(v.address)).active).to.equal(true);
expect((await st.validators(v.address)).totalDelegated).to.equal(D); // PRESERVED
expect(await st.validatorCount()).to.equal(1n); // no duplicate
// self-stake added to residual (475 + 500 = 975)
expect((await st.validators(v.address)).selfStake).to.equal(975n * ONE);
await st.connect(d).unbond(v.address, D); // no underflow
expect((await st.validators(v.address)).totalDelegated).to.equal(0n);
expect((await st.delegations(v.address, d.address)).amount).to.equal(0n);
});
});
// ───────────────────────── F5 ─────────────────────────
describe("F5 reward rate / block-time (APY 8%)", function () {
it("V1 REPRODUCES: constant is 315,360,000 (0.1s blocks) -> realized ~1.6% at 0.5s (5x under)", async () => {
const [v, d] = await ethers.getSigners();
const st = await deployV1();
await st.connect(v).registerValidator(0, { value: 500n * ONE });
const stake = 100n * ONE;
await st.connect(d).delegate(v.address, { value: stake });
const del = await st.delegations(v.address, d.address);
await ethers.provider.send("hardhat_mine", ["0x" + (500000n).toString(16)]);
const cur = BigInt(await ethers.provider.getBlockNumber());
const elapsed = cur - del.lastClaimBlock;
const pending = await st.pendingRewards(v.address, d.address);
expect(pending).to.equal((stake * 800n * elapsed) / (V1_BLOCKS_PER_YEAR * 10000n));
const BLOCKS_PER_YEAR_HALF_SEC = 63072000n; // 31,536,000 / 0.5
const realizedBps = (800n * BLOCKS_PER_YEAR_HALF_SEC) / V1_BLOCKS_PER_YEAR;
expect(realizedBps).to.equal(160n); // 1.60% realized vs 800 (8%) intended
expect(800n / realizedBps).to.equal(5n);
});
it("V2 CORRECTS: timestamp accrual -> 1000 AERE for 1 year accrues exactly 80 AERE (8%)", async () => {
const [owner, v, d] = await ethers.getSigners();
const st = await deployV2(owner.address);
expect(await st.rewardRateBps()).to.equal(800n);
await st.connect(v).registerValidator(0, { value: 500n * ONE });
const stake = 1000n * ONE;
await st.connect(d).delegate(v.address, { value: stake });
const L0 = (await st.delegations(v.address, d.address)).lastClaimTime;
await ethers.provider.send("evm_setNextBlockTimestamp", [Number(L0 + YEAR)]);
await ethers.provider.send("evm_mine", []);
const pending = await st.pendingRewards(v.address, d.address);
expect(pending).to.equal(80n * ONE); // 8% of 1000 AERE, exactly
expect(pending).to.equal(grossV2(stake, YEAR));
});
it("V2: rewardRateBps is owner-settable (guards + non-owner reject), and changes the accrual", async () => {
const [owner, v, d, stranger] = await ethers.getSigners();
const st = await deployV2(owner.address);
await expect(st.connect(stranger).setRewardRateBps(1600)).to.be.revertedWith(
"Ownable: caller is not the owner"
);
await expect(st.connect(owner).setRewardRateBps(0)).to.be.revertedWith(
"AereStakingV2: rate out of range"
);
await expect(st.connect(owner).setRewardRateBps(10001)).to.be.revertedWith(
"AereStakingV2: rate out of range"
);
await st.connect(owner).setRewardRateBps(1600); // 16% APY
expect(await st.rewardRateBps()).to.equal(1600n);
await st.connect(v).registerValidator(0, { value: 500n * ONE });
const stake = 1000n * ONE;
await st.connect(d).delegate(v.address, { value: stake });
const L0 = (await st.delegations(v.address, d.address)).lastClaimTime;
await ethers.provider.send("evm_setNextBlockTimestamp", [Number(L0 + YEAR)]);
await ethers.provider.send("evm_mine", []);
expect(await st.pendingRewards(v.address, d.address)).to.equal(160n * ONE); // 16% of 1000
});
});
// ───────────────────────── LOW: commission ─────────────────────────
describe("LOW validator commission is credited (not silently kept)", function () {
it("V1 REPRODUCES: commission is deducted from the delegator but credited nowhere (no validator claim)", async () => {
const st = await deployV1();
const names = st.interface.fragments.filter((f) => f.type === "function").map((f) => f.name);
expect(names).to.not.include("claimCommission");
// Validator struct has no commission-claimable field either.
const [v, d] = await ethers.getSigners();
await st.connect(v).registerValidator(1000, { value: 500n * ONE }); // 10% commission
await st.connect(d).delegate(v.address, { value: 1000n * ONE });
await ethers.provider.send("hardhat_mine", ["0x186A0"]); // 100000 blocks
const gross = ((1000n * ONE) * 800n * (BigInt(await ethers.provider.getBlockNumber()) -
(await st.delegations(v.address, d.address)).lastClaimBlock)) / (V1_BLOCKS_PER_YEAR * 10000n);
const net = await st.pendingRewards(v.address, d.address);
// delegator sees gross - 10%; the 10% commission exists but has no home in V1.
expect(net).to.equal(gross - gross / 10n);
});
it("V2 CORRECTS: commission accrues to the validator on settle and is withdrawable via claimCommission", async () => {
const [owner, v, d] = await ethers.getSigners();
const st = await deployV2(owner.address);
const addr = await st.getAddress();
await st.connect(v).registerValidator(1000, { value: 500n * ONE }); // 10% commission
await st.connect(d).delegate(v.address, { value: 1000n * ONE });
const L0 = (await st.delegations(v.address, d.address)).lastClaimTime;
// Seed the reward pool so claims can pay out.
await owner.sendTransaction({ to: addr, value: 100n * ONE });
// Lazy: before any settle, nothing is credited to the validator.
expect((await st.validators(v.address)).commissionAccrued).to.equal(0n);
// Delegator claims after 1 year -> settle splits gross 80 into 72 net + 8 commission.
await ethers.provider.send("evm_setNextBlockTimestamp", [Number(L0 + YEAR)]);
const dBefore = await ethers.provider.getBalance(d.address);
const rc = await (await st.connect(d).claimRewards(v.address)).wait();
const gas = rc.gasUsed * rc.gasPrice;
const dAfter = await ethers.provider.getBalance(d.address);
expect(dAfter + gas - dBefore).to.equal(72n * ONE); // 90% of 80
expect((await st.validators(v.address)).commissionAccrued).to.equal(8n * ONE); // 10% credited
// Validator withdraws the commission.
const vBefore = await ethers.provider.getBalance(v.address);
const rc2 = await (await st.connect(v).claimCommission()).wait();
const gas2 = rc2.gasUsed * rc2.gasPrice;
const vAfter = await ethers.provider.getBalance(v.address);
expect(vAfter + gas2 - vBefore).to.equal(8n * ONE); // commission paid out
expect((await st.validators(v.address)).commissionAccrued).to.equal(0n);
});
});
});