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