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