UNPKG

@vechain/vebetterdao-contracts

Version:

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

361 lines (360 loc) 21.8 kB
import { ethers } from "hardhat"; import { expect } from "chai"; import { describe, it, beforeEach } from "mocha"; import { getOrDeployContractInstances } from "../helpers/deploy"; import { bootstrapAndStartEmissions, getVot3Tokens, waitForRoundToEnd, waitForNextBlock, createProposal, payDeposit, waitForProposalToBeActive, getProposalIdFromTx, moveBlocks, } from "../helpers/common"; import { endorseApp } from "../helpers/xnodes"; describe("Navigator Staked B3TR as Voting Power - @shard19i", function () { let navigatorRegistry; let xAllocationVoting; let governor; let voterRewards; let b3tr; let vot3; let emissions; let veBetterPassport; let x2EarnApps; let owner; let minterAccount; let otherAccounts; let creators; let navigator; let navigator2; let citizen; let nonNavigator; let app1Id; let app2Id; const STAKE_AMOUNT = ethers.parseEther("50000"); const DELEGATE_AMOUNT = ethers.parseEther("500"); const METADATA_URI = "ipfs://nav-meta"; const fundAndApprove = async (account, amount) => { await b3tr.connect(owner).transfer(account.address, amount); await b3tr.connect(account).approve(await navigatorRegistry.getAddress(), amount); }; const advancePastExitDeadline = async (navigatorAddr) => { const filter = navigatorRegistry.filters.ExitAnnounced(navigatorAddr); const events = await navigatorRegistry.queryFilter(filter); const effectiveDeadline = events[events.length - 1].args.effectiveDeadline; const currentBlock = await xAllocationVoting.clock(); if (currentBlock < effectiveDeadline) { await moveBlocks(Number(effectiveDeadline - currentBlock) + 1); } }; async function setupFullEcosystem() { const deployment = await getOrDeployContractInstances({ forceDeploy: true }); if (!deployment) throw new Error("Failed to deploy contracts"); navigatorRegistry = deployment.navigatorRegistry; xAllocationVoting = deployment.xAllocationVoting; governor = deployment.governor; voterRewards = deployment.voterRewards; b3tr = deployment.b3tr; vot3 = deployment.vot3; emissions = deployment.emissions; veBetterPassport = deployment.veBetterPassport; x2EarnApps = deployment.x2EarnApps; owner = deployment.owner; minterAccount = deployment.minterAccount; otherAccounts = deployment.otherAccounts; creators = deployment.creators; navigator = otherAccounts[10]; navigator2 = otherAccounts[13]; citizen = otherAccounts[11]; nonNavigator = otherAccounts[14]; await b3tr.connect(minterAccount).mint(owner.address, ethers.parseEther("10000000")); await getVot3Tokens(otherAccounts[15], "10000000"); // Register navigator await fundAndApprove(navigator, STAKE_AMOUNT); await navigatorRegistry.connect(navigator).register(STAKE_AMOUNT, METADATA_URI); // Create and endorse 2 apps const creator1 = creators[0]; const creator2 = creators[1]; await x2EarnApps.connect(creator1).submitApp(creator1.address, creator1.address, "StakeTestApp1", "metadataURI"); app1Id = await x2EarnApps.hashAppName("StakeTestApp1"); await endorseApp(app1Id, otherAccounts[3]); await x2EarnApps.connect(creator2).submitApp(creator2.address, creator2.address, "StakeTestApp2", "metadataURI"); app2Id = await x2EarnApps.hashAppName("StakeTestApp2"); await endorseApp(app2Id, otherAccounts[4]); // Get VOT3 for citizen and delegate await getVot3Tokens(citizen, "1000"); await veBetterPassport.whitelist(citizen.address); await veBetterPassport.whitelist(navigator.address); if (!(await veBetterPassport.isCheckEnabled(1))) { await veBetterPassport.toggleCheck(1); } await bootstrapAndStartEmissions(); await waitForNextBlock(); // Citizen delegates to navigator await navigatorRegistry.connect(citizen).delegate(navigator.address, DELEGATE_AMOUNT); await waitForNextBlock(); } // ======================== 1. B3TR <-> VOT3 Conversion Accounting ======================== // describe("B3TR to VOT3 conversion accounting", function () { beforeEach(async function () { await setupFullEcosystem(); }); it("NavigatorRegistry holds VOT3 (not B3TR) after registration", async function () { const registryAddress = await navigatorRegistry.getAddress(); const vot3Balance = await vot3.balanceOf(registryAddress); const b3trBalance = await b3tr.balanceOf(registryAddress); expect(vot3Balance).to.equal(STAKE_AMOUNT); expect(b3trBalance).to.equal(0); }); it("getStake returns the staked amount after registration", async function () { expect(await navigatorRegistry.getStake(navigator.address)).to.equal(STAKE_AMOUNT); }); it("addStake increases VOT3 balance in registry", async function () { const addAmount = ethers.parseEther("10000"); await fundAndApprove(navigator, addAmount); await navigatorRegistry.connect(navigator).addStake(addAmount); const registryAddress = await navigatorRegistry.getAddress(); expect(await vot3.balanceOf(registryAddress)).to.equal(STAKE_AMOUNT + addAmount); expect(await navigatorRegistry.getStake(navigator.address)).to.equal(STAKE_AMOUNT + addAmount); }); it("reduceStake converts VOT3 back to B3TR and transfers to navigator", async function () { const addAmount = ethers.parseEther("20000"); await fundAndApprove(navigator, addAmount); await navigatorRegistry.connect(navigator).addStake(addAmount); const reduceAmount = ethers.parseEther("10000"); const b3trBefore = await b3tr.balanceOf(navigator.address); await navigatorRegistry.connect(navigator).reduceStake(reduceAmount); const b3trAfter = await b3tr.balanceOf(navigator.address); expect(b3trAfter - b3trBefore).to.equal(reduceAmount); const registryAddress = await navigatorRegistry.getAddress(); expect(await vot3.balanceOf(registryAddress)).to.equal(STAKE_AMOUNT + addAmount - reduceAmount); }); it("withdrawStake after exit converts VOT3 back to B3TR", async function () { await navigatorRegistry.connect(navigator).announceExit(); await advancePastExitDeadline(navigator.address); const b3trBefore = await b3tr.balanceOf(navigator.address); await navigatorRegistry.connect(navigator).withdrawStake(STAKE_AMOUNT); const b3trAfter = await b3tr.balanceOf(navigator.address); expect(b3trAfter - b3trBefore).to.equal(STAKE_AMOUNT); const registryAddress = await navigatorRegistry.getAddress(); expect(await vot3.balanceOf(registryAddress)).to.equal(0); }); it("multiple navigators: conversion accounting stays consistent", async function () { const stake2 = ethers.parseEther("60000"); await fundAndApprove(navigator2, stake2); await navigatorRegistry.connect(navigator2).register(stake2, METADATA_URI); const registryAddress = await navigatorRegistry.getAddress(); expect(await vot3.balanceOf(registryAddress)).to.equal(STAKE_AMOUNT + stake2); // Navigator 1 exits and withdraws await navigatorRegistry.connect(navigator).announceExit(); await advancePastExitDeadline(navigator.address); await navigatorRegistry.connect(navigator).withdrawStake(STAKE_AMOUNT); // Registry still holds navigator2's stake expect(await vot3.balanceOf(registryAddress)).to.equal(stake2); expect(await navigatorRegistry.getStake(navigator2.address)).to.equal(stake2); }); }); // ======================== 2. Staked Amount Checkpoints ======================== // describe("Staked amount checkpoints", function () { beforeEach(async function () { await setupFullEcosystem(); }); it("getStakedAmountAtTimepoint returns 0 before registration", async function () { expect(await navigatorRegistry.getStakedAmountAtTimepoint(navigator.address, 1)).to.equal(0); }); it("getStakedAmountAtTimepoint returns staked amount after registration block", async function () { const currentBlock = await ethers.provider.getBlockNumber(); expect(await navigatorRegistry.getStakedAmountAtTimepoint(navigator.address, currentBlock)).to.equal(STAKE_AMOUNT); }); it("getStakedAmountAtTimepoint tracks addStake correctly", async function () { const blockBeforeAdd = await ethers.provider.getBlockNumber(); const addAmount = ethers.parseEther("10000"); await fundAndApprove(navigator, addAmount); await navigatorRegistry.connect(navigator).addStake(addAmount); const blockAfterAdd = await ethers.provider.getBlockNumber(); expect(await navigatorRegistry.getStakedAmountAtTimepoint(navigator.address, blockBeforeAdd)).to.equal(STAKE_AMOUNT); expect(await navigatorRegistry.getStakedAmountAtTimepoint(navigator.address, blockAfterAdd)).to.equal(STAKE_AMOUNT + addAmount); }); it("getStakedAmountAtTimepoint returns 0 for non-navigators", async function () { const currentBlock = await ethers.provider.getBlockNumber(); expect(await navigatorRegistry.getStakedAmountAtTimepoint(nonNavigator.address, currentBlock)).to.equal(0); }); it("staked amount at past snapshot is correct even after later stake changes", async function () { const blockAtRegistration = await ethers.provider.getBlockNumber(); const addAmount = ethers.parseEther("10000"); await fundAndApprove(navigator, addAmount); await navigatorRegistry.connect(navigator).addStake(addAmount); // Historical query should still return original amount expect(await navigatorRegistry.getStakedAmountAtTimepoint(navigator.address, blockAtRegistration)).to.equal(STAKE_AMOUNT); // Current should return new total expect(await navigatorRegistry.getStake(navigator.address)).to.equal(STAKE_AMOUNT + addAmount); }); }); // ======================== 3. Allocation Voting Power Includes Staked Amount ======================== // describe("Allocation voting power includes staked B3TR", function () { let roundId; beforeEach(async function () { await setupFullEcosystem(); // Start a new round so snapshots capture the navigator registration const currentRound = await xAllocationVoting.currentRoundId(); await waitForRoundToEnd(Number(currentRound)); await emissions.distribute(); roundId = await xAllocationVoting.currentRoundId(); }); it("navigator's getVotes includes staked amount at round snapshot", async function () { const snapshot = await xAllocationVoting.roundSnapshot(roundId); // Advance past snapshot so getPastVotes doesn't revert with ERC5805FutureLookup await waitForNextBlock(); const votes = await xAllocationVoting.getVotes(navigator.address, snapshot); const personalVot3 = await vot3.getPastVotes(navigator.address, snapshot); const stakedAmount = await navigatorRegistry.getStakedAmountAtTimepoint(navigator.address, snapshot); expect(votes).to.equal(personalVot3 + stakedAmount); expect(stakedAmount).to.equal(STAKE_AMOUNT); }); it("non-navigator getVotes does NOT include any staked amount", async function () { const snapshot = await xAllocationVoting.roundSnapshot(roundId); await waitForNextBlock(); const votes = await xAllocationVoting.getVotes(nonNavigator.address, snapshot); const personalVot3 = await vot3.getPastVotes(nonNavigator.address, snapshot); expect(votes).to.equal(personalVot3); }); it("citizen delegated to navigator: getVotes returns delegated amount only", async function () { const snapshot = await xAllocationVoting.roundSnapshot(roundId); await waitForNextBlock(); const votes = await xAllocationVoting.getVotes(citizen.address, snapshot); expect(votes).to.equal(DELEGATE_AMOUNT); }); }); // ======================== 4. Governance Voting Power Includes Staked Amount ======================== // describe("Governance voting power includes staked B3TR", function () { beforeEach(async function () { await setupFullEcosystem(); }); it("navigator can castVote on governance proposal and weight includes staked amount", async function () { // Create a proposal (createProposal handles round advancement internally) const tx = await createProposal(b3tr, await ethers.getContractFactory("B3TR"), navigator, "Test proposal"); const proposalId = await getProposalIdFromTx(tx); await payDeposit(proposalId.toString(), navigator); await waitForProposalToBeActive(proposalId); // Cast vote (advances a block, so getPastVotes at snapshot won't revert with FutureLookup) const voteTx = await governor.connect(navigator).castVoteWithReason(proposalId, 1, "in favor"); const proposalSnapshot = await governor.proposalSnapshot(proposalId); const personalVot3 = await vot3.getPastVotes(navigator.address, proposalSnapshot); const stakedAmount = await navigatorRegistry.getStakedAmountAtTimepoint(navigator.address, proposalSnapshot); const expectedWeight = personalVot3 + stakedAmount; expect(stakedAmount).to.equal(STAKE_AMOUNT); const receipt = await voteTx.wait(); // Find VoteCast event, verify weight includes staked amount const voteCastEvent = receipt?.logs .map(log => { try { return governor.interface.parseLog(log); } catch { return null; } }) .find(e => e?.name === "VoteCast"); expect(voteCastEvent).to.not.be.null; expect(voteCastEvent.args.weight).to.equal(expectedWeight); }); }); // ======================== 5. Slash Accounting ======================== // describe("Slash converts VOT3 back to B3TR for treasury", function () { beforeEach(async function () { await setupFullEcosystem(); }); it("major slash: registry VOT3 decreases, navigator stake halved", async function () { const registryAddress = await navigatorRegistry.getAddress(); const slashPct = 5000n; // 50% const slashAmount = (STAKE_AMOUNT * slashPct) / 10000n; const vot3Before = await vot3.balanceOf(registryAddress); await navigatorRegistry.deactivateNavigator(navigator.address, slashPct, false); const vot3After = await vot3.balanceOf(registryAddress); expect(vot3Before - vot3After).to.equal(slashAmount); expect(await navigatorRegistry.getStake(navigator.address)).to.equal(STAKE_AMOUNT - slashAmount); }); it("major slash: staked amount checkpoint reflects new value", async function () { await navigatorRegistry.deactivateNavigator(navigator.address, 5000n, false); const currentBlock = await ethers.provider.getBlockNumber(); const stakeAtBlock = await navigatorRegistry.getStakedAmountAtTimepoint(navigator.address, currentBlock); expect(stakeAtBlock).to.equal(STAKE_AMOUNT / 2n); }); it("minor slash: VOT3 converted to B3TR, checkpoint updated", async function () { // Start new round so navigator has citizens at snapshot const currentRound = await xAllocationVoting.currentRoundId(); await waitForRoundToEnd(Number(currentRound)); await emissions.distribute(); // Navigator has citizens but does NOT set preferences → missed allocation infraction const roundToReport = await xAllocationVoting.currentRoundId(); await waitForRoundToEnd(Number(roundToReport)); await emissions.distribute(); const registryAddress = await navigatorRegistry.getAddress(); const vot3Before = await vot3.balanceOf(registryAddress); const stakeBefore = await navigatorRegistry.getStake(navigator.address); await navigatorRegistry.reportRoundInfractions(navigator.address, roundToReport, []); const stakeAfter = await navigatorRegistry.getStake(navigator.address); const vot3After = await vot3.balanceOf(registryAddress); expect(stakeAfter).to.be.lt(stakeBefore); expect(vot3Before - vot3After).to.equal(stakeBefore - stakeAfter); const currentBlock = await ethers.provider.getBlockNumber(); expect(await navigatorRegistry.getStakedAmountAtTimepoint(navigator.address, currentBlock)).to.equal(stakeAfter); }); }); // ======================== 6. Edge Cases ======================== // describe("Edge cases", function () { beforeEach(async function () { await setupFullEcosystem(); }); it("NavigatorRegistry address has 0 staked amount (no double counting)", async function () { const registryAddress = await navigatorRegistry.getAddress(); const currentBlock = await ethers.provider.getBlockNumber(); // Registry holds VOT3 and self-delegates, but getStakedAmountAtTimepoint // returns 0 because the registry is not a registered navigator const stakedAtTimepoint = await navigatorRegistry.getStakedAmountAtTimepoint(registryAddress, currentBlock); expect(stakedAtTimepoint).to.equal(0); }); it("staked VOT3 is in registry, not in navigator's personal wallet", async function () { const personalBalance = await vot3.balanceOf(navigator.address); const registryBalance = await vot3.balanceOf(await navigatorRegistry.getAddress()); // Navigator's personal balance should NOT include staked amount expect(registryBalance).to.be.gte(STAKE_AMOUNT); // Personal balance is separate from staked amount expect(personalBalance).to.not.equal(personalBalance + STAKE_AMOUNT); }); it("VOT3 supply increases after staking (B3TR->VOT3 mints)", async function () { const supplyBefore = await vot3.totalSupply(); const stake2 = ethers.parseEther("60000"); await fundAndApprove(navigator2, stake2); await navigatorRegistry.connect(navigator2).register(stake2, METADATA_URI); const supplyAfter = await vot3.totalSupply(); expect(supplyAfter - supplyBefore).to.equal(stake2); }); it("VOT3 supply decreases after withdrawal (VOT3->B3TR burns)", async function () { await navigatorRegistry.connect(navigator).announceExit(); await advancePastExitDeadline(navigator.address); const supplyBefore = await vot3.totalSupply(); await navigatorRegistry.connect(navigator).withdrawStake(STAKE_AMOUNT); const supplyAfter = await vot3.totalSupply(); expect(supplyBefore - supplyAfter).to.equal(STAKE_AMOUNT); }); it("slash after partial reduce: conversion accounting stays correct", async function () { const addAmount = ethers.parseEther("20000"); await fundAndApprove(navigator, addAmount); await navigatorRegistry.connect(navigator).addStake(addAmount); const reduceAmount = ethers.parseEther("10000"); await navigatorRegistry.connect(navigator).reduceStake(reduceAmount); const stakeBeforeSlash = await navigatorRegistry.getStake(navigator.address); const registryVot3Before = await vot3.balanceOf(await navigatorRegistry.getAddress()); const slashPct = 5000n; const expectedSlash = (stakeBeforeSlash * slashPct) / 10000n; await navigatorRegistry.deactivateNavigator(navigator.address, slashPct, false); const stakeAfterSlash = await navigatorRegistry.getStake(navigator.address); const registryVot3After = await vot3.balanceOf(await navigatorRegistry.getAddress()); expect(stakeAfterSlash).to.equal(stakeBeforeSlash - expectedSlash); expect(registryVot3Before - registryVot3After).to.equal(expectedSlash); }); it("after full withdrawal, staked amount is 0 and getVotes returns only personal VOT3", async function () { await navigatorRegistry.connect(navigator).announceExit(); await advancePastExitDeadline(navigator.address); await navigatorRegistry.connect(navigator).withdrawStake(STAKE_AMOUNT); expect(await navigatorRegistry.getStake(navigator.address)).to.equal(0); const currentBlock = await ethers.provider.getBlockNumber(); expect(await navigatorRegistry.getStakedAmountAtTimepoint(navigator.address, currentBlock)).to.equal(0); }); }); });