UNPKG

@vechain/vebetterdao-contracts

Version:

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

782 lines 59.3 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"; import { GovernorVotesLogic__factory } from "../../typechain-types/factories/contracts/governance/libraries/GovernorVotesLogic__factory"; describe("NavigatorRegistry Integration - @shard19g", function () { let navigatorRegistry; let xAllocationVoting; let governor; let voterRewards; let b3tr; let vot3; let emissions; let veBetterPassport; let x2EarnApps; let relayerRewardsPool; let governorVotesLogicLib; let owner; let minterAccount; let otherAccounts; let creators; let navigator; let citizen; let relayer; let app1Id; let app2Id; const STAKE_AMOUNT = ethers.parseEther("50000"); const DELEGATE_AMOUNT = ethers.parseEther("500"); const CITIZEN_VOT3 = "1000"; const METADATA_URI = "ipfs://nav-meta"; // Helper: fund B3TR to an account and approve NavigatorRegistry const fundAndApprove = async (account, amount) => { await b3tr.connect(owner).transfer(account.address, amount); await b3tr.connect(account).approve(await navigatorRegistry.getAddress(), amount); }; /** * Full ecosystem bootstrap: * - Deploy all contracts fresh * - Create and endorse 2 apps * - Register a navigator with B3TR stake * - Get VOT3 for citizen and delegate to navigator * - Whitelist citizen in passport * - Bootstrap and start emissions */ 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; relayerRewardsPool = deployment.relayerRewardsPool; governorVotesLogicLib = deployment.governorVotesLogicLib; owner = deployment.owner; minterAccount = deployment.minterAccount; otherAccounts = deployment.otherAccounts; creators = deployment.creators; navigator = otherAccounts[10]; citizen = otherAccounts[11]; relayer = otherAccounts[12]; // Mint B3TR to owner for distribution await b3tr.connect(minterAccount).mint(owner.address, ethers.parseEther("10000000")); // Create VOT3 supply (maxStakePercentage requires enough VOT3 supply for 50k B3TR stake) 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 (each app needs a unique creator with their own NFT) const creator1 = creators[0]; // otherAccounts[0] const creator2 = creators[1]; // otherAccounts[1] await x2EarnApps.connect(creator1).submitApp(creator1.address, creator1.address, "IntegrationApp1", "metadataURI"); app1Id = await x2EarnApps.hashAppName("IntegrationApp1"); await endorseApp(app1Id, otherAccounts[3]); await x2EarnApps.connect(creator2).submitApp(creator2.address, creator2.address, "IntegrationApp2", "metadataURI"); app2Id = await x2EarnApps.hashAppName("IntegrationApp2"); await endorseApp(app2Id, otherAccounts[4]); // Get VOT3 for citizen await getVot3Tokens(citizen, CITIZEN_VOT3); // Whitelist citizen in passport await veBetterPassport.whitelist(citizen.address); if (!(await veBetterPassport.isCheckEnabled(1))) { await veBetterPassport.toggleCheck(1); } // Register relayer on the relayer rewards pool (required for early access period) await relayerRewardsPool.registerRelayer(relayer.address); // Bootstrap and start emissions await bootstrapAndStartEmissions(); // Wait one block so delegation snapshot is after emissions start await waitForNextBlock(); // Citizen delegates 500 VOT3 to navigator await navigatorRegistry.connect(citizen).delegate(navigator.address, DELEGATE_AMOUNT); // Wait a block so the delegation checkpoint is recorded await waitForNextBlock(); } // ======================== 1. XAllocationVoting: castNavigatorVote ======================== // describe("castNavigatorVote on XAllocationVoting", function () { let roundId; beforeEach(async function () { await setupFullEcosystem(); // Start a new round so the delegation snapshot captures the delegation const currentRound = await xAllocationVoting.currentRoundId(); await waitForRoundToEnd(Number(currentRound)); await emissions.distribute(); roundId = await xAllocationVoting.currentRoundId(); // Navigator sets allocation preferences: 60/40 split await navigatorRegistry.connect(navigator).setAllocationPreferences(roundId, [app1Id, app2Id], [6000, 4000]); }); it("happy path: cast vote, verify vote counted in round totals", async function () { await xAllocationVoting.connect(relayer).castNavigatorVote(citizen.address, roundId); expect(await xAllocationVoting.hasVoted(roundId, citizen.address)).to.be.true; const totalVotes = await xAllocationVoting.totalVotes(roundId); expect(totalVotes).to.equal(DELEGATE_AMOUNT); }); it("voting power equals delegated amount (500), not full balance (1000)", async function () { await xAllocationVoting.connect(relayer).castNavigatorVote(citizen.address, roundId); // Total votes should be 500 (delegated), not 1000 (full VOT3 balance) const totalVotes = await xAllocationVoting.totalVotes(roundId); expect(totalVotes).to.equal(ethers.parseEther("500")); // Citizen has 1000 VOT3 total const citizenBalance = await vot3.balanceOf(citizen.address); expect(citizenBalance).to.equal(ethers.parseEther("1000")); }); it("60/40 split: app1 gets 300 weight, app2 gets 200 weight", async function () { await xAllocationVoting.connect(relayer).castNavigatorVote(citizen.address, roundId); // 500 * 6000/10000 = 300 const app1Votes = await xAllocationVoting.getAppVotes(roundId, app1Id); expect(app1Votes).to.equal(ethers.parseEther("300")); // 500 * 4000/10000 = 200 const app2Votes = await xAllocationVoting.getAppVotes(roundId, app2Id); expect(app2Votes).to.equal(ethers.parseEther("200")); // Total = 300 + 200 = 500 const totalVotes = await xAllocationVoting.totalVotes(roundId); expect(totalVotes).to.equal(ethers.parseEther("500")); }); it("reverts NotDelegatedToNavigator when citizen is not delegated", async function () { const nonDelegated = otherAccounts[13]; await getVot3Tokens(nonDelegated, "1000"); await veBetterPassport.whitelist(nonDelegated.address); await expect(xAllocationVoting.connect(relayer).castNavigatorVote(nonDelegated.address, roundId)).to.be.revertedWithCustomError(xAllocationVoting, "NotDelegatedToNavigator"); }); it("skips when navigator has no preferences (after skip window)", async function () { // Start another round where navigator hasn't set preferences await waitForRoundToEnd(Number(roundId)); await emissions.distribute(); const newRoundId = await xAllocationVoting.currentRoundId(); // Advance past the skip window so skip is permitted const skipWindow = await xAllocationVoting.citizenSkipWindowBlocks(); const deadline = await xAllocationVoting.roundDeadline(newRoundId); const currentBlock = BigInt(await ethers.provider.getBlockNumber()); const blocksToMine = deadline - currentBlock - skipWindow; if (blocksToMine > 0n) await moveBlocks(Number(blocksToMine)); await expect(xAllocationVoting.connect(relayer).castNavigatorVote(citizen.address, newRoundId)).to.emit(xAllocationVoting, "NavigatorVoteSkipped"); }); it("relayer action VOTE is registered for caller", async function () { const weightedBefore = await relayerRewardsPool.totalRelayerWeightedActions(relayer.address, roundId); await xAllocationVoting.connect(relayer).castNavigatorVote(citizen.address, roundId); const weightedAfter = await relayerRewardsPool.totalRelayerWeightedActions(relayer.address, roundId); expect(weightedAfter).to.be.gt(weightedBefore); }); }); // ======================== 2. B3TRGovernor: castNavigatorVote ======================== // describe("castNavigatorVote on B3TRGovernor", function () { let roundId; let proposalId; beforeEach(async function () { await setupFullEcosystem(); // Advance to a new round so delegation snapshot is captured const currentRound = await xAllocationVoting.currentRoundId(); await waitForRoundToEnd(Number(currentRound)); await emissions.distribute(); roundId = await xAllocationVoting.currentRoundId(); // Give owner VOT3 to create proposals (needs enough for deposit) await getVot3Tokens(owner, "300000"); await waitForNextBlock(); // Create a proposal const tx = await createProposal(b3tr, await ethers.getContractFactory("B3TR"), owner, "Nav integration test"); proposalId = await getProposalIdFromTx(tx); // Pay deposit and wait for proposal to become active await payDeposit(proposalId.toString(), owner); await waitForProposalToBeActive(proposalId); // Navigator sets decision: 2 = For (stored as 1-indexed: 1=Against, 2=For, 3=Abstain) await navigatorRegistry.connect(navigator).setProposalDecision(proposalId, 2); }); it("happy path: cast navigator vote, verify vote counted as For", async function () { await governor.connect(relayer).castNavigatorVote(proposalId, citizen.address); expect(await governor.hasVoted(proposalId, citizen.address)).to.be.true; // proposalVotes returns (againstVotes, forVotes, abstainVotes) const [, forVotes] = await governor.proposalVotes(proposalId); // forVotes should include the citizen's delegated amount (sqrt-based power) expect(forVotes).to.be.gt(0n); }); it("voting power equals delegated amount at proposal snapshot", async function () { const tx = await governor.connect(relayer).castNavigatorVote(proposalId, citizen.address); const receipt = await tx.wait(); // Find NavigatorGovernanceVoteCast event (emitted from GovernorVotesLogic library) const libraryInterface = GovernorVotesLogic__factory.createInterface(); const event = receipt?.logs .map(log => { try { return libraryInterface.parseLog({ topics: [...log.topics], data: log.data }); } catch { return null; } }) .find(e => e?.name === "NavigatorGovernanceVoteCast"); expect(event).to.not.be.undefined; // weight arg (index 4) should equal DELEGATE_AMOUNT expect(event?.args[4]).to.equal(DELEGATE_AMOUNT); }); it("skips governance vote when no decision set (after skip window)", async function () { // Create a new proposal without setting a navigator decision const nextRound = (await xAllocationVoting.currentRoundId()) + 1n; const tx2 = await createProposal(b3tr, await ethers.getContractFactory("B3TR"), owner, "No decision proposal", "tokenDetails", [], nextRound.toString()); const newProposalId = await getProposalIdFromTx(tx2); await payDeposit(newProposalId.toString(), owner); await waitForProposalToBeActive(newProposalId); // Advance past the governance skip window const skipWindow = await governor.governanceSkipWindowBlocks(); const currentRoundId = await xAllocationVoting.currentRoundId(); const deadline = await xAllocationVoting.roundDeadline(currentRoundId); const currentBlock = BigInt(await ethers.provider.getBlockNumber()); const blocksToMine = deadline - currentBlock - skipWindow; if (blocksToMine > 0n) await moveBlocks(Number(blocksToMine)); const tx3 = await governor.connect(relayer).castNavigatorVote(newProposalId, citizen.address); const receipt = await tx3.wait(); const libraryInterface = GovernorVotesLogic__factory.createInterface(); const event = receipt?.logs .map(log => { try { return libraryInterface.parseLog({ topics: [...log.topics], data: log.data }); } catch { return null; } }) .find(e => e?.name === "NavigatorGovernanceVoteSkipped"); expect(event).to.not.be.undefined; }); it("reverts NotDelegatedToNavigator when citizen is not delegated", async function () { const nonDelegated = otherAccounts[14]; await expect(governor.connect(relayer).castNavigatorVote(proposalId, nonDelegated.address)).to.be.revertedWithCustomError(governorVotesLogicLib, "NotDelegatedToNavigator"); }); it("registers relayer governance vote action for caller", async function () { const proposalRoundId = await governor.proposalStartRound(proposalId); const weightedBefore = await relayerRewardsPool.totalRelayerWeightedActions(relayer.address, proposalRoundId); await governor.connect(relayer).castNavigatorVote(proposalId, citizen.address); const weightedAfter = await relayerRewardsPool.totalRelayerWeightedActions(relayer.address, proposalRoundId); expect(weightedAfter - weightedBefore).to.equal(await relayerRewardsPool.getVoteWeight()); }); }); describe("Relayer expected actions with navigator citizens", function () { it("startNewRound includes delegated citizens and active governance proposals", async function () { await setupFullEcosystem(); await getVot3Tokens(owner, "300000"); await waitForNextBlock(); const currentRound = await xAllocationVoting.currentRoundId(); const targetRound = currentRound + 1n; const createTx = await createProposal(b3tr, await ethers.getContractFactory("B3TR"), owner, "Expected actions proposal", "tokenDetails", [], targetRound.toString()); const proposalId = await getProposalIdFromTx(createTx); await payDeposit(proposalId.toString(), owner); await waitForRoundToEnd(Number(currentRound)); await emissions.distribute(); const newRoundId = await xAllocationVoting.currentRoundId(); await waitForProposalToBeActive(proposalId); expect(newRoundId).to.equal(targetRound); expect(await governor.getActiveProposals()).to.deep.equal([proposalId]); expect(await relayerRewardsPool.totalActions(newRoundId)).to.equal(3); expect(await relayerRewardsPool.totalWeightedActions(newRoundId)).to.equal(7); }); it("castNavigatorVote skips when navigator has no preferences (after skip window)", async function () { await setupFullEcosystem(); const currentRound = await xAllocationVoting.currentRoundId(); await waitForRoundToEnd(Number(currentRound)); await emissions.distribute(); const roundId = await xAllocationVoting.currentRoundId(); // Navigator does NOT set preferences for this round const totalActionsBefore = await relayerRewardsPool.totalActions(roundId); const totalWeightedBefore = await relayerRewardsPool.totalWeightedActions(roundId); const voteWeight = await relayerRewardsPool.getVoteWeight(); const claimWeight = await relayerRewardsPool.getClaimWeight(); // Advance to skip window const skipWindow = await xAllocationVoting.citizenSkipWindowBlocks(); const deadline = await xAllocationVoting.roundDeadline(roundId); const currentBlock = BigInt(await ethers.provider.getBlockNumber()); const blocksToMine = deadline - currentBlock - skipWindow; if (blocksToMine > 0n) await moveBlocks(Number(blocksToMine)); // castNavigatorVote should skip (not vote) and reduce allocation vote. // With no active governance proposals, claim is also auto-reduced (all vote actions done). await xAllocationVoting.connect(relayer).castNavigatorVote(citizen.address, roundId); // 2 actions reduced: vote + auto-claim expect(await relayerRewardsPool.totalActions(roundId)).to.equal(totalActionsBefore - 2n); expect(await relayerRewardsPool.totalWeightedActions(roundId)).to.equal(totalWeightedBefore - voteWeight - claimWeight); }); it("castNavigatorVote with no preferences skips after advancing past skip window", async function () { await setupFullEcosystem(); const currentRound = await xAllocationVoting.currentRoundId(); await waitForRoundToEnd(Number(currentRound)); await emissions.distribute(); const roundId = await xAllocationVoting.currentRoundId(); // Advance past the skip window so skip is permitted const skipWindow = await xAllocationVoting.citizenSkipWindowBlocks(); const deadline = await xAllocationVoting.roundDeadline(roundId); const currentBlock = BigInt(await ethers.provider.getBlockNumber()); const blocksToMine = deadline - currentBlock - skipWindow; if (blocksToMine > 0n) await moveBlocks(Number(blocksToMine)); await expect(xAllocationVoting.connect(relayer).castNavigatorVote(citizen.address, roundId)).to.emit(xAllocationVoting, "NavigatorVoteSkipped"); }); it("castNavigatorVote skips immediately when navigator is dead", async function () { await setupFullEcosystem(); await getVot3Tokens(owner, "300000"); await waitForNextBlock(); const currentRound = await xAllocationVoting.currentRoundId(); const targetRound = currentRound + 1n; const createTx = await createProposal(b3tr, await ethers.getContractFactory("B3TR"), owner, "Dead navigator proposal", "tokenDetails", [], targetRound.toString()); const proposalId = await getProposalIdFromTx(createTx); await payDeposit(proposalId.toString(), owner); await waitForRoundToEnd(Number(currentRound)); await emissions.distribute(); const roundId = await xAllocationVoting.currentRoundId(); await waitForProposalToBeActive(proposalId); // Kill navigator after round starts await navigatorRegistry.connect(owner).deactivateNavigator(navigator.address, 0, false); const totalActionsBefore = await relayerRewardsPool.totalActions(roundId); // Allocation skip — immediate, no skip window needed await xAllocationVoting.connect(relayer).castNavigatorVote(citizen.address, roundId); // Governance skip await governor.connect(relayer).castNavigatorVote(proposalId, citizen.address); // All vote actions skipped → claim auto-reduced: allocation (1) + governance (1) + claim (1) = 3 expect(await relayerRewardsPool.totalActions(roundId)).to.equal(totalActionsBefore - 3n); }); it("governance castNavigatorVote skips when navigator has no decision", async function () { await setupFullEcosystem(); await getVot3Tokens(owner, "300000"); await waitForNextBlock(); const currentRound = await xAllocationVoting.currentRoundId(); const targetRound = currentRound + 1n; const createTx = await createProposal(b3tr, await ethers.getContractFactory("B3TR"), owner, "No decision proposal", "tokenDetails", [], targetRound.toString()); const proposalId = await getProposalIdFromTx(createTx); await payDeposit(proposalId.toString(), owner); await waitForRoundToEnd(Number(currentRound)); await emissions.distribute(); const roundId = await xAllocationVoting.currentRoundId(); await waitForProposalToBeActive(proposalId); const totalActionsBefore = await relayerRewardsPool.totalActions(roundId); const voteWeight = await relayerRewardsPool.getVoteWeight(); // Advance past the governance skip window so skip is permitted const skipWindow = await governor.governanceSkipWindowBlocks(); const deadline = await xAllocationVoting.roundDeadline(roundId); const currentBlock = BigInt(await ethers.provider.getBlockNumber()); const blocksToMine = deadline - currentBlock - skipWindow; if (blocksToMine > 0n) await moveBlocks(Number(blocksToMine)); await governor.connect(relayer).castNavigatorVote(proposalId, citizen.address); expect(await relayerRewardsPool.totalActions(roundId)).to.equal(totalActionsBefore - 1n); }); }); // ======================== Passport validation for castNavigatorVote ======================== // describe("castNavigatorVote passport validation", function () { it("XAllocationVoting: skips vote when citizen has no valid passport", async function () { await setupFullEcosystem(); const currentRound = await xAllocationVoting.currentRoundId(); await waitForRoundToEnd(Number(currentRound)); await emissions.distribute(); const roundId = await xAllocationVoting.currentRoundId(); await navigatorRegistry.connect(navigator).setAllocationPreferences(roundId, [app1Id, app2Id], [6000, 4000]); // Remove citizen from whitelist so they fail personhood await veBetterPassport.removeFromWhitelist(citizen.address); const totalActionsBefore = await relayerRewardsPool.totalActions(roundId); // Should skip (emit NavigatorVoteSkipped), not revert await expect(xAllocationVoting.connect(relayer).castNavigatorVote(citizen.address, roundId)).to.emit(xAllocationVoting, "NavigatorVoteSkipped"); // Citizen should NOT have voted expect(await xAllocationVoting.hasVoted(roundId, citizen.address)).to.be.false; // Expected actions reduced (vote + auto-claim) expect(await relayerRewardsPool.totalActions(roundId)).to.be.lt(totalActionsBefore); }); it("B3TRGovernor: skips vote when citizen has no valid passport", async function () { await setupFullEcosystem(); const currentRound = await xAllocationVoting.currentRoundId(); await waitForRoundToEnd(Number(currentRound)); await emissions.distribute(); const roundId = await xAllocationVoting.currentRoundId(); await getVot3Tokens(owner, "300000"); await waitForNextBlock(); const tx = await createProposal(b3tr, await ethers.getContractFactory("B3TR"), owner, "Passport test proposal"); const proposalId = await getProposalIdFromTx(tx); await payDeposit(proposalId.toString(), owner); await waitForProposalToBeActive(proposalId); await navigatorRegistry.connect(navigator).setProposalDecision(proposalId, 2); // Remove citizen from whitelist so they fail personhood await veBetterPassport.removeFromWhitelist(citizen.address); // Should skip (emit NavigatorGovernanceVoteSkipped), not revert const voteTx = await governor.connect(relayer).castNavigatorVote(proposalId, citizen.address); const receipt = await voteTx.wait(); const libraryInterface = GovernorVotesLogic__factory.createInterface(); const event = receipt?.logs .map(log => { try { return libraryInterface.parseLog({ topics: [...log.topics], data: log.data }); } catch { return null; } }) .find(e => e?.name === "NavigatorGovernanceVoteSkipped"); expect(event).to.not.be.undefined; // Citizen should NOT have voted expect(await governor.hasVoted(proposalId, citizen.address)).to.be.false; }); it("relayer is not penalized when citizen passport is invalid", async function () { await setupFullEcosystem(); const currentRound = await xAllocationVoting.currentRoundId(); await waitForRoundToEnd(Number(currentRound)); await emissions.distribute(); const roundId = await xAllocationVoting.currentRoundId(); await navigatorRegistry.connect(navigator).setAllocationPreferences(roundId, [app1Id, app2Id], [6000, 4000]); // Remove citizen from whitelist await veBetterPassport.removeFromWhitelist(citizen.address); // Relayer can still call castNavigatorVote without reverting // (skip reduces expected actions so relayer doesn't lose rewards for unperformable actions) await expect(xAllocationVoting.connect(relayer).castNavigatorVote(citizen.address, roundId)).to.not.be.reverted; }); it("citizen with valid passport can still vote normally via navigator", async function () { await setupFullEcosystem(); const currentRound = await xAllocationVoting.currentRoundId(); await waitForRoundToEnd(Number(currentRound)); await emissions.distribute(); const roundId = await xAllocationVoting.currentRoundId(); await navigatorRegistry.connect(navigator).setAllocationPreferences(roundId, [app1Id, app2Id], [6000, 4000]); // Citizen is whitelisted (from setupFullEcosystem) — should vote normally await xAllocationVoting.connect(relayer).castNavigatorVote(citizen.address, roundId); expect(await xAllocationVoting.hasVoted(roundId, citizen.address)).to.be.true; expect(await xAllocationVoting.totalVotes(roundId)).to.equal(DELEGATE_AMOUNT); }); it("XAllocationVoting: non-person citizen reverts before skip window when navigator has no preferences", async function () { await setupFullEcosystem(); const currentRound = await xAllocationVoting.currentRoundId(); await waitForRoundToEnd(Number(currentRound)); await emissions.distribute(); const roundId = await xAllocationVoting.currentRoundId(); // Navigator does NOT set preferences // Remove citizen from whitelist so they fail personhood await veBetterPassport.removeFromWhitelist(citizen.address); // Before skip window → should revert, not skip prematurely await expect(xAllocationVoting.connect(relayer).castNavigatorVote(citizen.address, roundId)).to.be.revertedWithCustomError(xAllocationVoting, "SkipWindowNotReached"); }); it("XAllocationVoting: non-person citizen skips after skip window when navigator has no preferences", async function () { await setupFullEcosystem(); const currentRound = await xAllocationVoting.currentRoundId(); await waitForRoundToEnd(Number(currentRound)); await emissions.distribute(); const roundId = await xAllocationVoting.currentRoundId(); // Navigator does NOT set preferences // Remove citizen from whitelist so they fail personhood await veBetterPassport.removeFromWhitelist(citizen.address); // Advance past the skip window const skipWindow = await xAllocationVoting.citizenSkipWindowBlocks(); const deadline = await xAllocationVoting.roundDeadline(roundId); const currentBlock = BigInt(await ethers.provider.getBlockNumber()); const blocksToMine = deadline - currentBlock - skipWindow; if (blocksToMine > 0n) await moveBlocks(Number(blocksToMine)); // After skip window → should skip (navigator failed to act, personhood irrelevant) await expect(xAllocationVoting.connect(relayer).castNavigatorVote(citizen.address, roundId)).to.emit(xAllocationVoting, "NavigatorVoteSkipped"); }); it("B3TRGovernor: non-person citizen reverts before skip window when navigator has no decision", async function () { await setupFullEcosystem(); const currentRound = await xAllocationVoting.currentRoundId(); await waitForRoundToEnd(Number(currentRound)); await emissions.distribute(); await getVot3Tokens(owner, "300000"); await waitForNextBlock(); const tx = await createProposal(b3tr, await ethers.getContractFactory("B3TR"), owner, "Non-person no-decision proposal"); const proposalId = await getProposalIdFromTx(tx); await payDeposit(proposalId.toString(), owner); await waitForProposalToBeActive(proposalId); // Navigator does NOT set a decision // Remove citizen from whitelist so they fail personhood await veBetterPassport.removeFromWhitelist(citizen.address); // Before skip window → should revert, not skip prematurely await expect(governor.connect(relayer).castNavigatorVote(proposalId, citizen.address)).to.be.revertedWithCustomError(governorVotesLogicLib, "GovernanceSkipWindowNotReached"); }); it("B3TRGovernor: non-person citizen skips after skip window when navigator has no decision", async function () { await setupFullEcosystem(); const currentRound = await xAllocationVoting.currentRoundId(); await waitForRoundToEnd(Number(currentRound)); await emissions.distribute(); const roundId = await xAllocationVoting.currentRoundId(); await getVot3Tokens(owner, "300000"); await waitForNextBlock(); const tx = await createProposal(b3tr, await ethers.getContractFactory("B3TR"), owner, "Non-person skip-window proposal"); const proposalId = await getProposalIdFromTx(tx); await payDeposit(proposalId.toString(), owner); await waitForProposalToBeActive(proposalId); // Navigator does NOT set a decision // Remove citizen from whitelist so they fail personhood await veBetterPassport.removeFromWhitelist(citizen.address); // Advance past the governance skip window (uses proposal deadline, not round deadline) const skipWindow = await governor.governanceSkipWindowBlocks(); const deadline = await governor.proposalDeadline(proposalId); const currentBlock = BigInt(await ethers.provider.getBlockNumber()); const blocksToMine = deadline - currentBlock - skipWindow; if (blocksToMine > 0n) await moveBlocks(Number(blocksToMine)); // After skip window → should skip (navigator failed to act, personhood irrelevant) const voteTx = await governor.connect(relayer).castNavigatorVote(proposalId, citizen.address); const receipt = await voteTx.wait(); const libraryInterface = GovernorVotesLogic__factory.createInterface(); const event = receipt?.logs .map(log => { try { return libraryInterface.parseLog({ topics: [...log.topics], data: log.data }); } catch { return null; } }) .find(e => e?.name === "NavigatorGovernanceVoteSkipped"); expect(event).to.not.be.undefined; expect(await governor.hasVoted(proposalId, citizen.address)).to.be.false; }); }); // ======================== 3. VoterRewards: fee deduction ======================== // describe("VoterRewards fee deduction", function () { let roundId; beforeEach(async function () { await setupFullEcosystem(); // Advance to a new round const currentRound = await xAllocationVoting.currentRoundId(); await waitForRoundToEnd(Number(currentRound)); await emissions.distribute(); roundId = await xAllocationVoting.currentRoundId(); // Navigator sets preferences and casts vote for citizen await navigatorRegistry.connect(navigator).setAllocationPreferences(roundId, [app1Id, app2Id], [6000, 4000]); await xAllocationVoting.connect(relayer).castNavigatorVote(citizen.address, roundId); // End the round so rewards can be claimed await waitForRoundToEnd(Number(roundId)); await emissions.distribute(); }); it("navigator fee is deducted from citizen's gross reward on claim", async function () { // getReward returns netReward (after all fees), so compute gross from components const netReward = await voterRewards.getReward(roundId, citizen.address); const netGmReward = await voterRewards.getGMReward(roundId, citizen.address); const relayerFee = await voterRewards.getRelayerFee(roundId, citizen.address); const navigatorFee = await voterRewards.getNavigatorFee(roundId, citizen.address); const grossReward = netReward + netGmReward + relayerFee + navigatorFee; expect(grossReward).to.be.gt(0n); expect(navigatorFee).to.be.gt(0n); // Claim reward const citizenBalanceBefore = await b3tr.balanceOf(citizen.address); await voterRewards.connect(relayer).claimReward(roundId, citizen.address); const citizenBalanceAfter = await b3tr.balanceOf(citizen.address); // Citizen receives netReward + netGmReward, less than gross reward const received = citizenBalanceAfter - citizenBalanceBefore; expect(received).to.equal(netReward + netGmReward); expect(received).to.be.lt(grossReward); }); it("navigator fee = feePercentage (2000 = 20%) of gross reward", async function () { const feePercentage = await navigatorRegistry.getFeePercentage(); expect(feePercentage).to.equal(2000n); // Compute gross reward from all components const netReward = await voterRewards.getReward(roundId, citizen.address); const netGmReward = await voterRewards.getGMReward(roundId, citizen.address); const relayerFee = await voterRewards.getRelayerFee(roundId, citizen.address); const navigatorFee = await voterRewards.getNavigatorFee(roundId, citizen.address); const grossReward = netReward + netGmReward + relayerFee + navigatorFee; // Fee should be 20% of gross reward const expectedFee = (grossReward * 2000n) / 10000n; expect(navigatorFee).to.equal(expectedFee); }); it("relayer fee is applied on remainder after navigator fee", async function () { const grossReward = await voterRewards.getReward(roundId, citizen.address); const navigatorFee = await voterRewards.getNavigatorFee(roundId, citizen.address); const relayerFee = await voterRewards.getRelayerFee(roundId, citizen.address); // Relayer fee is computed on (grossReward - navigatorFee), not on grossReward const afterNavFee = grossReward - navigatorFee; // Use the pool's own calculation to verify const expectedRelayerFee = await relayerRewardsPool.calculateRelayerFee(afterNavFee); expect(relayerFee).to.equal(expectedRelayerFee); }); it("fee deposited to NavigatorRegistry (check getRoundFee)", async function () { const feeBefore = await navigatorRegistry.getRoundFee(navigator.address, roundId); expect(feeBefore).to.equal(0n); // Read navigator fee before claim (claim resets cycle data) const navigatorFee = await voterRewards.getNavigatorFee(roundId, citizen.address); expect(navigatorFee).to.be.gt(0n); await voterRewards.connect(relayer).claimReward(roundId, citizen.address); const feeAfter = await navigatorRegistry.getRoundFee(navigator.address, roundId); expect(feeAfter).to.be.gt(0n); // Should match the navigator fee amount expect(feeAfter).to.equal(navigatorFee); }); it("getNavigatorFee view returns correct amount before claim", async function () { // Compute gross reward from all components const netReward = await voterRewards.getReward(roundId, citizen.address); const netGmReward = await voterRewards.getGMReward(roundId, citizen.address); const relayerFee = await voterRewards.getRelayerFee(roundId, citizen.address); const navigatorFee = await voterRewards.getNavigatorFee(roundId, citizen.address); const grossReward = netReward + netGmReward + relayerFee + navigatorFee; // Verify the fee matches manual calculation const feePercentage = await navigatorRegistry.getFeePercentage(); const expected = (grossReward * feePercentage) / 10000n; expect(navigatorFee).to.equal(expected); // Verify it's not zero (citizen did vote and has rewards) expect(navigatorFee).to.be.gt(0n); }); }); // ======================== Snapshot-consistent delegation ======================== // describe("snapshot-consistent delegation", function () { beforeEach(async function () { await setupFullEcosystem(); }); it("mid-round delegation: getVotes unchanged until next round", async function () { const newCitizen = otherAccounts[13]; await getVot3Tokens(newCitizen, "1000"); await veBetterPassport.whitelist(newCitizen.address); await waitForNextBlock(); // Advance to a new round const currentRound = await xAllocationVoting.currentRoundId(); await waitForRoundToEnd(Number(currentRound)); await emissions.distribute(); const roundId = await xAllocationVoting.currentRoundId(); await waitForNextBlock(); const snapshot = await xAllocationVoting.roundSnapshot(roundId); // getVotes at round snapshot = full balance (not delegated at snapshot) const votesBefore = await xAllocationVoting.getVotes(newCitizen.address, snapshot); expect(votesBefore).to.equal(ethers.parseEther("1000")); // Delegate mid-round await navigatorRegistry.connect(newCitizen).delegate(navigator.address, ethers.parseEther("500")); await waitForNextBlock(); // getVotes at round snapshot is STILL full balance (snapshot is before delegation) const votesAfter = await xAllocationVoting.getVotes(newCitizen.address, snapshot); expect(votesAfter).to.equal(ethers.parseEther("1000")); }); it("mid-round undelegate: citizen blocked from manual vote this round", async function () { // Advance to a new round const currentRound = await xAllocationVoting.currentRoundId(); await waitForRoundToEnd(Number(currentRound)); await emissions.distribute(); const roundId = await xAllocationVoting.currentRoundId(); await waitForNextBlock(); // citizen was delegated before round start, so snapshot shows delegation // Undelegate mid-round await navigatorRegistry.connect(citizen).undelegate(); await waitForNextBlock(); // Manual vote should still be blocked (was delegated at snapshot, navigator alive) await expect(xAllocationVoting.connect(citizen).castVote(roundId, [app1Id], [ethers.parseEther("500")])).to.be.revertedWithCustomError(xAllocationVoting, "DelegatedToNavigator"); }); it("castNavigatorVote uses snapshot navigator, not current", async function () { // Advance to a new round const currentRound = await xAllocationVoting.currentRoundId(); await waitForRoundToEnd(Number(currentRound)); await emissions.distribute(); const roundId = await xAllocationVoting.currentRoundId(); await waitForNextBlock(); // Navigator sets preferences await navigatorRegistry.connect(navigator).setAllocationPreferences(roundId, [app1Id, app2Id], [5000, 5000]); // citizen was delegated to navigator at snapshot // Undelegate mid-round (current = no navigator, snapshot = navigator) await navigatorRegistry.connect(citizen).undelegate(); await waitForNextBlock(); // castNavigatorVote should still work (uses snapshot navigator) await xAllocationVoting.connect(relayer).castNavigatorVote(citizen.address, roundId); expect(await xAllocationVoting.hasVoted(roundId, citizen.address)).to.be.true; }); it("getVotes returns full balance when navigator is dead at timepoint", async function () { // Deactivate the navigator — checkpoint written at roundDeadline(currentRound) await navigatorRegistry.deactivateNavigator(navigator.address, 0, false); // Wait for round to end so the timepoint is past the deadline checkpoint const currentRound = await xAllocationVoting.currentRoundId(); await waitForRoundToEnd(Number(currentRound)); await emissions.distribute(); await waitForNextBlock(); const block = await ethers.provider.getBlockNumber(); const timepoint = block - 1; // Navigator is dead at this timepoint (after round deadline) => full balance const govVotes = await governor.getVotes(citizen.address, timepoint); const xAllocVotes = await xAllocationVoting.getVotes(citizen.address, timepoint); expect(govVotes).to.equal(ethers.parseEther(CITIZEN_VOT3)); expect(xAllocVotes).to.equal(ethers.parseEther(CITIZEN_VOT3)); }); it("getVotes returns delegated amount at timepoint before navigator death", async function () { const blockBeforeDeath = await ethers.provider.getBlockNumber(); await waitForNextBlock(); // Deactivate the navigator await navigatorRegistry.deactivateNavigator(navigator.address, 0, false); await waitForNextBlock(); // At the timepoint BEFORE death, navigator was alive => returns delegated amount const govVotes = await governor.getVotes(citizen.address, blockBeforeDeath); const xAllocVotes = await xAllocationVoting.getVotes(citizen.address, blockBeforeDeath); expect(govVotes).to.equal(DELEGATE_AMOUNT); expect(xAllocVotes).to.equal(DELEGATE_AMOUNT); }); it("double-vote prevention: delegated at snapshot blocks castVote", async function () { // Advance to a new round const currentRound = await xAllocationVoting.currentRoundId(); await waitForRoundToEnd(Number(currentRound)); await emissions.distribute(); const roundId = await xAllocationVoting.currentRoundId(); await waitForNextBlock(); // citizen was delegated at snapshot => manual vote should be blocked await expect(xAllocationVoting.connect(citizen).castVote(roundId, [app1Id], [ethers.parseEther("500")])).to.be.revertedWithCustomError(xAllocationVoting, "DelegatedToNavigator"); }); it("dead navigator before round start allows manual voting", async function () { // Deactivate the navigator BEFORE advancing to next round await navigatorRegistry.deactivateNavigator(navigator.address, 0, false); // Now advance to a new round — snapshot captures navigator as already dead const currentRound = await xAllocationVoting.currentRoundId(); await waitForRoundToEnd(Number(currentRound)); await emissions.distribute(); const roundId = await xAllocationVoting.currentRoundId(); await waitForNextBlock(); // Manual vote should be allowed (navigator was dead at snapshot) await xAllocationVoting.connect(citizen).castVote(roundId, [app1Id], [ethers.parseEther("500")]); expect(await xAllocationVoting.hasVoted(roundId, citizen.address)).to.be.true; }); it("getNavigatorAtTimepoint returns correct navigator at past block", async function () { const block = await ethers.provider.getBlockNumber(); const timepoint = block - 1; const navAtTimepoint = await navigatorRegistry.getNavigatorAtTimepoint(citizen.address, timepoint); expect(navAtTimepoint).to.equal(navigator.address); }); it("getNavigatorAtTimepoint returns zero before delegation", async function () { const newCitizen = otherAccounts[14]; await getVot3Tokens(newCitizen, "1000"); await waitForNextBlock(); const block = await ethers.provider.getBlockNumber(); const timepoint = block - 1; const navAtTimepoint = await navigatorRegistry.getNavigatorAtTimepoint(newCitizen.address, timepoint); expect(navAtTimepoint).to.equal(ethers.ZeroAddress); }); it("isDeactivatedAtTimepoint tracks navigator lifecycle", async function () { const blockBeforeExit = await ethers.provider.getBlockNumber(); // Navigator announces exit — checkpoint is at a FUTURE block (round deadline + notice period) await navigatorRegistry.connect(navigator).announceExit(); await waitForNextBlock(); // Before exit announcement: not deactivated expect(await navigatorRegistry.isDeactivatedAtTimepoint(navigator.address, blockBeforeExit)).to.be.false; // Immediately after announcement: NOT yet deactivated (checkpoint is in the future) const blockAfterExit = await ethers.provider.getBlockNumber(); expect(await navigatorRegistry.isDeactivatedAtTimepoint(navigator.address, blockAfterExit - 1)).to.be.false; // But isExiting is true expect(await navigatorRegistry.isExiting(navigator.address)).to.be.true; }); it("isDelegatedAtTimepoint returns false after undelegation", async function () { const blockBefore = await ethers.provider.getBlockNumber(); await navigatorRegistry.connect(citizen).undelegate(); await waitForNextBlock(); const blockAfter = await ethers.provider.getBlockNumber(); // Was delegated before undelegation expect(await navigatorRegistry.isDelegatedAtTimepoint(citizen.address, blockBefore)).to.be.true; // Not delegated after undelegation expect(await navigatorRegistry.isDelegatedAtTimepoint(citizen.address, blockAfter - 1)).to.be.false; }); }); // ======================== 4. VOT3 lock integration ======================== // describe("VOT3 lock integration", function () { beforeEach(async function () { await setupFullEcosystem(); }); it("delegate 500 of 1000: transfer 500 OK, transfer 501 fails", async function () { // Citizen has 1000 VOT3, 500 delegated => 500 unlocked const recipient = otherAccounts[9]; // Transfer 500 should succeed (exactly the unlocked portion) await expect(vot3.connect(citizen).transfer(recipient.address, ethers.parseEther("500"))).to.not.be.reverted; // Get citizen new balance (should be 500 — all locked) const balance = await vot3.balanceOf(citizen.address); expect(balance).to.equal(ethers.parseEther("500")); // Attempting to transfer even 1 wei should fail now (entire balance is locked) await expect(vot3.connect(ci