UNPKG

@vechain/vebetterdao-contracts

Version:

Open-source repository that houses the smart contracts powering the decentralized VeBetterDAO on the VeChain Thor blockchain.

373 lines (372 loc) 26.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const hardhat_1 = require("hardhat"); const chai_1 = require("chai"); const mocha_1 = require("mocha"); const deploy_1 = require("../helpers/deploy"); const common_1 = require("../helpers/common"); (0, mocha_1.describe)("NavigatorRegistry Slashing - @shard19e", function () { let navigatorRegistry; let b3tr; let xAllocationVoting; let emissions; let treasury; let owner; let minterAccount; let otherAccounts; let navigator1; let citizen1; const STAKE_AMOUNT = hardhat_1.ethers.parseEther("50000"); const METADATA_URI = "ipfs://navigator-metadata"; const app1 = hardhat_1.ethers.keccak256(hardhat_1.ethers.toUtf8Bytes("App1")); const FLAG_MISSED_ALLOCATION = 1n; const FLAG_LATE_PREFERENCES = 2n; const FLAG_STALE_PREFERENCES = 4n; const FLAG_MISSED_REPORT = 8n; const FLAG_MISSED_GOVERNANCE = 16n; const FLAG_BELOW_MIN_STAKE = 32n; // Helper: fund account with B3TR (via owner) and approve NavigatorRegistry const fundAndApprove = async (account, amount) => { await b3tr.connect(owner).transfer(account.address, amount); const registryAddress = await navigatorRegistry.getAddress(); await b3tr.connect(account).approve(registryAddress, amount); }; // Helper: register a navigator with default stake const registerNavigator = async (account, amount = STAKE_AMOUNT) => { await fundAndApprove(account, amount); await navigatorRegistry.connect(account).register(amount, METADATA_URI); }; // Helper: delegate citizen to navigator (gets VOT3 tokens first) const delegateCitizen = async (citizen, navigator, amount = "1000") => { await (0, common_1.getVot3Tokens)(citizen, amount); await navigatorRegistry.connect(citizen).delegate(navigator.address, hardhat_1.ethers.parseEther(amount)); }; // Helper: advance N allocation rounds const advanceRounds = async (count) => { for (let i = 0; i < count; i++) { const roundId = await xAllocationVoting.currentRoundId(); await (0, common_1.waitForRoundToEnd)(Number(roundId)); await emissions.distribute(); } }; (0, mocha_1.beforeEach)(async function () { const deployment = await (0, deploy_1.getOrDeployContractInstances)({ forceDeploy: true }); if (!deployment) throw new Error("Failed to deploy contracts"); navigatorRegistry = deployment.navigatorRegistry; b3tr = deployment.b3tr; xAllocationVoting = deployment.xAllocationVoting; emissions = deployment.emissions; treasury = deployment.treasury; owner = deployment.owner; minterAccount = deployment.minterAccount; otherAccounts = deployment.otherAccounts; navigator1 = otherAccounts[10]; citizen1 = otherAccounts[11]; // Mint B3TR to owner for transfers await b3tr.connect(minterAccount).mint(owner.address, hardhat_1.ethers.parseEther("10000000")); // Create VOT3 supply (max stake = 1% of VOT3 supply, need >= 5M VOT3 for 50k stake) await (0, common_1.getVot3Tokens)(otherAccounts[15], "10000000"); // Register navigator await registerNavigator(navigator1); }); (0, mocha_1.describe)("reportRoundInfractions()", function () { (0, mocha_1.it)("reverts when reporting an active round", async function () { await delegateCitizen(citizen1, navigator1); await (0, common_1.bootstrapAndStartEmissions)(); const roundId = await xAllocationVoting.currentRoundId(); await (0, chai_1.expect)(navigatorRegistry.reportRoundInfractions(navigator1.address, roundId, [])).to.be.revertedWithCustomError(navigatorRegistry, "RoundStillActive"); }); (0, mocha_1.it)("slashes once even when multiple infractions are true", async function () { await delegateCitizen(citizen1, navigator1); await (0, common_1.bootstrapAndStartEmissions)(); await advanceRounds(2); const roundId = await xAllocationVoting.currentRoundId(); await (0, common_1.waitForRoundToEnd)(Number(roundId)); const stakeBefore = await navigatorRegistry.getStake(navigator1.address); await navigatorRegistry.reportRoundInfractions(navigator1.address, roundId, [999n]); const stakeAfter = await navigatorRegistry.getStake(navigator1.address); const expectedSlash = (stakeBefore * 500n) / 10000n; (0, chai_1.expect)(stakeAfter).to.equal(stakeBefore - expectedSlash); const [slashed, flags] = await navigatorRegistry.isSlashedForRound(navigator1.address, roundId); (0, chai_1.expect)(slashed).to.equal(true); (0, chai_1.expect)(flags).to.equal(FLAG_MISSED_ALLOCATION | FLAG_STALE_PREFERENCES | FLAG_MISSED_REPORT | FLAG_MISSED_GOVERNANCE); }); (0, mocha_1.it)("detects late preferences as infraction", async function () { await delegateCitizen(citizen1, navigator1); await (0, common_1.bootstrapAndStartEmissions)(); const roundId = await xAllocationVoting.currentRoundId(); await navigatorRegistry.connect(owner).setPreferenceCutoffPeriod(2); const deadline = await xAllocationVoting.roundDeadline(roundId); const cutoff = deadline - 2n; const currentBlock = await xAllocationVoting.clock(); const blocksToAdvance = Number(cutoff - currentBlock) + 1; if (blocksToAdvance > 0) { await (0, common_1.moveBlocks)(blocksToAdvance); } await navigatorRegistry.connect(navigator1).setAllocationPreferences(roundId, [app1], [10000]); await (0, common_1.waitForRoundToEnd)(Number(roundId)); await navigatorRegistry.reportRoundInfractions(navigator1.address, roundId, []); const [slashed, flags] = await navigatorRegistry.isSlashedForRound(navigator1.address, roundId); (0, chai_1.expect)(slashed).to.equal(true); (0, chai_1.expect)(flags).to.equal(FLAG_LATE_PREFERENCES); }); (0, mocha_1.it)("reverts NoInfractionFound when no infraction exists", async function () { await delegateCitizen(citizen1, navigator1); await (0, common_1.bootstrapAndStartEmissions)(); const roundId = await xAllocationVoting.currentRoundId(); await navigatorRegistry.connect(owner).setPreferenceCutoffPeriod(2); await navigatorRegistry.connect(navigator1).setAllocationPreferences(roundId, [app1], [10000]); await (0, common_1.waitForRoundToEnd)(Number(roundId)); await (0, chai_1.expect)(navigatorRegistry.reportRoundInfractions(navigator1.address, roundId, [])).to.be.revertedWithCustomError(navigatorRegistry, "NoInfractionFound"); }); (0, mocha_1.it)("reverts NoInfractionFound when navigator had no delegations", async function () { await (0, common_1.bootstrapAndStartEmissions)(); const roundId = await xAllocationVoting.currentRoundId(); await (0, common_1.waitForRoundToEnd)(Number(roundId)); await (0, chai_1.expect)(navigatorRegistry.reportRoundInfractions(navigator1.address, roundId, [])).to.be.revertedWithCustomError(navigatorRegistry, "NoInfractionFound"); }); (0, mocha_1.it)("reverts AlreadySlashed when reporting same round twice", async function () { await delegateCitizen(citizen1, navigator1); await (0, common_1.bootstrapAndStartEmissions)(); const roundId = await xAllocationVoting.currentRoundId(); await (0, common_1.waitForRoundToEnd)(Number(roundId)); await navigatorRegistry.reportRoundInfractions(navigator1.address, roundId, []); await (0, chai_1.expect)(navigatorRegistry.reportRoundInfractions(navigator1.address, roundId, [])).to.be.revertedWithCustomError(navigatorRegistry, "AlreadySlashed"); }); }); (0, mocha_1.describe)("Minor slash compounding", function () { (0, mocha_1.it)("compounds across rounds: 50000 -> 47500 -> 45125", async function () { await delegateCitizen(citizen1, navigator1); await (0, common_1.bootstrapAndStartEmissions)(); const round1 = await xAllocationVoting.currentRoundId(); await (0, common_1.waitForRoundToEnd)(Number(round1)); await navigatorRegistry.reportRoundInfractions(navigator1.address, round1, []); (0, chai_1.expect)(await navigatorRegistry.getStake(navigator1.address)).to.equal(hardhat_1.ethers.parseEther("47500")); await emissions.distribute(); const round2 = await xAllocationVoting.currentRoundId(); await (0, common_1.waitForRoundToEnd)(Number(round2)); await navigatorRegistry.reportRoundInfractions(navigator1.address, round2, []); (0, chai_1.expect)(await navigatorRegistry.getStake(navigator1.address)).to.equal(hardhat_1.ethers.parseEther("45125")); const expectedTotal = hardhat_1.ethers.parseEther("2500") + hardhat_1.ethers.parseEther("2375"); (0, chai_1.expect)(await navigatorRegistry.getTotalSlashed(navigator1.address)).to.equal(expectedTotal); }); }); (0, mocha_1.describe)("Treasury verification", function () { (0, mocha_1.it)("increases treasury B3TR balance by slashed amount", async function () { await delegateCitizen(citizen1, navigator1); await (0, common_1.bootstrapAndStartEmissions)(); const roundId = await xAllocationVoting.currentRoundId(); await (0, common_1.waitForRoundToEnd)(Number(roundId)); const treasuryAddress = await treasury.getAddress(); const treasuryBefore = await b3tr.balanceOf(treasuryAddress); const stakeBefore = await navigatorRegistry.getStake(navigator1.address); await navigatorRegistry.reportRoundInfractions(navigator1.address, roundId, []); const expectedSlash = (stakeBefore * 500n) / 10000n; const treasuryAfter = await b3tr.balanceOf(treasuryAddress); (0, chai_1.expect)(treasuryAfter - treasuryBefore).to.equal(expectedSlash); }); }); (0, mocha_1.describe)("FLAG_BELOW_MIN_STAKE", function () { // Set minStake to STAKE_AMOUNT so slashing drops navigator below it (0, mocha_1.beforeEach)(async function () { await navigatorRegistry.connect(owner).setMinStake(STAKE_AMOUNT); }); (0, mocha_1.it)("slashes navigator below minStake with delegations — includes belowMinStake flag alongside other infractions", async function () { await delegateCitizen(citizen1, navigator1); await (0, common_1.bootstrapAndStartEmissions)(); // Round 1: slash to drop stake below minStake (50000 -> 47500) const round1 = await xAllocationVoting.currentRoundId(); await (0, common_1.waitForRoundToEnd)(Number(round1)); await navigatorRegistry.reportRoundInfractions(navigator1.address, round1, []); (0, chai_1.expect)(await navigatorRegistry.getStake(navigator1.address)).to.equal(hardhat_1.ethers.parseEther("47500")); // Round 2: now below minStake — should include FLAG_BELOW_MIN_STAKE await emissions.distribute(); const round2 = await xAllocationVoting.currentRoundId(); await (0, common_1.waitForRoundToEnd)(Number(round2)); await navigatorRegistry.reportRoundInfractions(navigator1.address, round2, []); const [slashed, flags] = await navigatorRegistry.isSlashedForRound(navigator1.address, round2); (0, chai_1.expect)(slashed).to.equal(true); (0, chai_1.expect)(flags & FLAG_BELOW_MIN_STAKE).to.equal(FLAG_BELOW_MIN_STAKE); (0, chai_1.expect)(flags & FLAG_MISSED_ALLOCATION).to.equal(FLAG_MISSED_ALLOCATION); }); (0, mocha_1.it)("slashes navigator below minStake without delegations — only belowMinStake flag", async function () { await delegateCitizen(citizen1, navigator1); await (0, common_1.bootstrapAndStartEmissions)(); // Round 1: slash to drop below minStake const round1 = await xAllocationVoting.currentRoundId(); await (0, common_1.waitForRoundToEnd)(Number(round1)); await navigatorRegistry.reportRoundInfractions(navigator1.address, round1, []); (0, chai_1.expect)(await navigatorRegistry.getStake(navigator1.address)).to.equal(hardhat_1.ethers.parseEther("47500")); // Citizen undelegates — navigator now has no delegations await navigatorRegistry.connect(citizen1).undelegate(); // Round 2: below minStake + no delegations — still slashable await emissions.distribute(); const round2 = await xAllocationVoting.currentRoundId(); await (0, common_1.waitForRoundToEnd)(Number(round2)); await navigatorRegistry.reportRoundInfractions(navigator1.address, round2, []); const [slashed, flags] = await navigatorRegistry.isSlashedForRound(navigator1.address, round2); (0, chai_1.expect)(slashed).to.equal(true); (0, chai_1.expect)(flags).to.equal(FLAG_BELOW_MIN_STAKE); }); (0, mocha_1.it)("does not set belowMinStake flag when stake equals minStake", async function () { await delegateCitizen(citizen1, navigator1); await (0, common_1.bootstrapAndStartEmissions)(); const roundId = await xAllocationVoting.currentRoundId(); await (0, common_1.waitForRoundToEnd)(Number(roundId)); // Stake is exactly 50,000 = minStake, so belowMinStake should NOT be set await navigatorRegistry.reportRoundInfractions(navigator1.address, roundId, []); const [, flags] = await navigatorRegistry.isSlashedForRound(navigator1.address, roundId); (0, chai_1.expect)(flags & FLAG_BELOW_MIN_STAKE).to.equal(0n); }); (0, mocha_1.it)("navigator at minStake without delegations still reverts NoInfractionFound", async function () { await (0, common_1.bootstrapAndStartEmissions)(); const roundId = await xAllocationVoting.currentRoundId(); await (0, common_1.waitForRoundToEnd)(Number(roundId)); await (0, chai_1.expect)(navigatorRegistry.reportRoundInfractions(navigator1.address, roundId, [])).to.be.revertedWithCustomError(navigatorRegistry, "NoInfractionFound"); }); (0, mocha_1.it)("navigator recovers above minStake — belowMinStake flag clears on next round", async function () { await delegateCitizen(citizen1, navigator1); await (0, common_1.bootstrapAndStartEmissions)(); // Round 1: slash to 47,500 const round1 = await xAllocationVoting.currentRoundId(); await (0, common_1.waitForRoundToEnd)(Number(round1)); await navigatorRegistry.reportRoundInfractions(navigator1.address, round1, []); // Navigator tops up stake back above minStake await fundAndApprove(navigator1, hardhat_1.ethers.parseEther("5000")); await navigatorRegistry.connect(navigator1).addStake(hardhat_1.ethers.parseEther("5000")); (0, chai_1.expect)(await navigatorRegistry.getStake(navigator1.address)).to.equal(hardhat_1.ethers.parseEther("52500")); // Round 2: navigator does their duties await emissions.distribute(); const round2 = await xAllocationVoting.currentRoundId(); await navigatorRegistry.connect(navigator1).setAllocationPreferences(round2, [app1], [10000]); await navigatorRegistry.connect(navigator1).submitReport("ipfs://report"); await (0, common_1.waitForRoundToEnd)(Number(round2)); // Report should revert — no infractions (above minStake + duties done) await (0, chai_1.expect)(navigatorRegistry.reportRoundInfractions(navigator1.address, round2, [])).to.be.revertedWithCustomError(navigatorRegistry, "NoInfractionFound"); }); }); (0, mocha_1.describe)("FLAG_BELOW_MIN_STAKE timing", function () { (0, mocha_1.beforeEach)(async function () { await navigatorRegistry.connect(owner).setMinStake(STAKE_AMOUNT); }); (0, mocha_1.it)("topping up during a round prevents belowMinStake — navigator recovered before round end", async function () { await delegateCitizen(citizen1, navigator1); await (0, common_1.bootstrapAndStartEmissions)(); // Round 1: slash drops 50k -> 47.5k const round1 = await xAllocationVoting.currentRoundId(); await (0, common_1.waitForRoundToEnd)(Number(round1)); await navigatorRegistry.reportRoundInfractions(navigator1.address, round1, []); (0, chai_1.expect)(await navigatorRegistry.getStake(navigator1.address)).to.equal(hardhat_1.ethers.parseEther("47500")); // Round 2 starts — snapshot captures 47.5k (below 50k min) await emissions.distribute(); const round2 = await xAllocationVoting.currentRoundId(); // Navigator tops up DURING round 2 — recovers before round ends await fundAndApprove(navigator1, hardhat_1.ethers.parseEther("5000")); await navigatorRegistry.connect(navigator1).addStake(hardhat_1.ethers.parseEther("5000")); (0, chai_1.expect)(await navigatorRegistry.getStake(navigator1.address)).to.equal(hardhat_1.ethers.parseEther("52500")); // Navigator also does duties await navigatorRegistry.connect(navigator1).setAllocationPreferences(round2, [app1], [10000]); await navigatorRegistry.connect(navigator1).submitReport("ipfs://report"); await (0, common_1.waitForRoundToEnd)(Number(round2)); // belowMinStake should NOT fire — navigator was below at start but recovered by end await (0, chai_1.expect)(navigatorRegistry.reportRoundInfractions(navigator1.address, round2, [])).to.be.revertedWithCustomError(navigatorRegistry, "NoInfractionFound"); }); (0, mocha_1.it)("mid-round slash: navigator slashed in round N is NOT below-min for round N, only for round N+1", async function () { // Navigator starts at exactly minStake (50k) await delegateCitizen(citizen1, navigator1); await (0, common_1.bootstrapAndStartEmissions)(); // Round 1: navigator misses duties → slashed (50k -> 47.5k) // At round 1 snapshot, stake was 50k (= minStake) → NOT below min for round 1 const round1 = await xAllocationVoting.currentRoundId(); await (0, common_1.waitForRoundToEnd)(Number(round1)); await navigatorRegistry.reportRoundInfractions(navigator1.address, round1, []); const [, flagsR1] = await navigatorRegistry.isSlashedForRound(navigator1.address, round1); (0, chai_1.expect)(flagsR1 & FLAG_BELOW_MIN_STAKE).to.equal(0n, "should NOT have belowMinStake for round where slash happened"); // Round 2: snapshot captures 47.5k → below min await emissions.distribute(); const round2 = await xAllocationVoting.currentRoundId(); await (0, common_1.waitForRoundToEnd)(Number(round2)); await navigatorRegistry.reportRoundInfractions(navigator1.address, round2, []); const [, flagsR2] = await navigatorRegistry.isSlashedForRound(navigator1.address, round2); (0, chai_1.expect)(flagsR2 & FLAG_BELOW_MIN_STAKE).to.equal(FLAG_BELOW_MIN_STAKE, "should have belowMinStake for next round"); }); (0, mocha_1.it)("navigator has full round to recover — slashed only if still below at round end", async function () { await delegateCitizen(citizen1, navigator1); await (0, common_1.bootstrapAndStartEmissions)(); // Round 1: slash drops 50k -> 47.5k const round1 = await xAllocationVoting.currentRoundId(); await (0, common_1.waitForRoundToEnd)(Number(round1)); await navigatorRegistry.reportRoundInfractions(navigator1.address, round1, []); // Round 2: starts at 47.5k, navigator does NOT top up → still 47.5k at end await emissions.distribute(); const round2 = await xAllocationVoting.currentRoundId(); await (0, common_1.waitForRoundToEnd)(Number(round2)); // Should be slashed: below min at start AND at end await navigatorRegistry.reportRoundInfractions(navigator1.address, round2, []); const [, flagsR2] = await navigatorRegistry.isSlashedForRound(navigator1.address, round2); (0, chai_1.expect)(flagsR2 & FLAG_BELOW_MIN_STAKE).to.equal(FLAG_BELOW_MIN_STAKE); }); (0, mocha_1.it)("topping up BEFORE next round starts prevents belowMinStake flag", async function () { await delegateCitizen(citizen1, navigator1); await (0, common_1.bootstrapAndStartEmissions)(); // Round 1: slash to 47.5k const round1 = await xAllocationVoting.currentRoundId(); await (0, common_1.waitForRoundToEnd)(Number(round1)); await navigatorRegistry.reportRoundInfractions(navigator1.address, round1, []); // Navigator tops up BEFORE round 2 starts (between rounds) await fundAndApprove(navigator1, hardhat_1.ethers.parseEther("5000")); await navigatorRegistry.connect(navigator1).addStake(hardhat_1.ethers.parseEther("5000")); (0, chai_1.expect)(await navigatorRegistry.getStake(navigator1.address)).to.equal(hardhat_1.ethers.parseEther("52500")); // Start round 2 — snapshot captures 52.5k (above 50k min) await emissions.distribute(); const round2 = await xAllocationVoting.currentRoundId(); await navigatorRegistry.connect(navigator1).setAllocationPreferences(round2, [app1], [10000]); await navigatorRegistry.connect(navigator1).submitReport("ipfs://report"); await (0, common_1.waitForRoundToEnd)(Number(round2)); // No belowMinStake infraction await (0, chai_1.expect)(navigatorRegistry.reportRoundInfractions(navigator1.address, round2, [])).to.be.revertedWithCustomError(navigatorRegistry, "NoInfractionFound"); }); }); (0, mocha_1.describe)("deactivateNavigator() / majorSlash", function () { (0, mocha_1.it)("should revert when called by non-governance role", async function () { const nonGovernance = otherAccounts[14]; await (0, chai_1.expect)(navigatorRegistry.connect(nonGovernance).deactivateNavigator(navigator1.address, 5000, false)).to.be .reverted; }); (0, mocha_1.it)("should slash 50% of 50000 = 25000", async function () { await navigatorRegistry.connect(owner).deactivateNavigator(navigator1.address, 5000, false); (0, chai_1.expect)(await navigatorRegistry.getStake(navigator1.address)).to.equal(hardhat_1.ethers.parseEther("25000")); (0, chai_1.expect)(await navigatorRegistry.isDeactivated(navigator1.address)).to.equal(true); }); (0, mocha_1.it)("should forfeit fees when slashFees=true — claimFee reverts after deactivation", async function () { await delegateCitizen(citizen1, navigator1); await (0, common_1.bootstrapAndStartEmissions)(); const roundId = await xAllocationVoting.currentRoundId(); // Deposit a fee via impersonation of VoterRewards const voterRewardsAddress = await (await (0, deploy_1.getOrDeployContractInstances)({ forceDeploy: false })).voterRewards.getAddress(); await hardhat_1.ethers.provider.send("hardhat_impersonateAccount", [voterRewardsAddress]); await hardhat_1.ethers.provider.send("hardhat_setBalance", [voterRewardsAddress, "0x" + (10n ** 18n).toString(16)]); const voterRewardsSigner = await hardhat_1.ethers.getSigner(voterRewardsAddress); const registryAddress = await navigatorRegistry.getAddress(); const feeAmount = hardhat_1.ethers.parseEther("100"); await b3tr.connect(owner).transfer(registryAddress, feeAmount); await navigatorRegistry.connect(voterRewardsSigner).depositNavigatorFee(navigator1.address, roundId, feeAmount); await hardhat_1.ethers.provider.send("hardhat_stopImpersonatingAccount", [voterRewardsAddress]); // Deactivate with fee slashing await navigatorRegistry.connect(owner).deactivateNavigator(navigator1.address, 5000, true); // claimFee reverts with NotRegistered (onlyNavigator modifier blocks deactivated navigators) await (0, chai_1.expect)(navigatorRegistry.connect(navigator1).claimFee(roundId)).to.be.revertedWithCustomError(navigatorRegistry, "NotRegistered"); }); (0, mocha_1.it)("should slash 100% — stake becomes 0", async function () { await navigatorRegistry.connect(owner).deactivateNavigator(navigator1.address, 10000, false); (0, chai_1.expect)(await navigatorRegistry.getStake(navigator1.address)).to.equal(0n); (0, chai_1.expect)(await navigatorRegistry.isDeactivated(navigator1.address)).to.equal(true); }); (0, mocha_1.it)("should deactivate with 0% slash — stake unchanged", async function () { await navigatorRegistry.connect(owner).deactivateNavigator(navigator1.address, 0, false); (0, chai_1.expect)(await navigatorRegistry.getStake(navigator1.address)).to.equal(STAKE_AMOUNT); (0, chai_1.expect)(await navigatorRegistry.isDeactivated(navigator1.address)).to.equal(true); }); }); });