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.
345 lines
18 KiB
JavaScript
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);
|
|
});
|
|
});
|
|
});
|