UNPKG

@vechain/vebetterdao-contracts

Version:

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

737 lines 63.7 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"); const xnodes_1 = require("../helpers/xnodes"); const GovernorVotesLogic__factory_1 = require("../../typechain-types/factories/contracts/governance/libraries/GovernorVotesLogic__factory"); (0, mocha_1.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 = hardhat_1.ethers.parseEther("50000"); const DELEGATE_AMOUNT = hardhat_1.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 (0, deploy_1.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, hardhat_1.ethers.parseEther("10000000")); // Create VOT3 supply (maxStakePercentage requires enough VOT3 supply for 50k B3TR stake) await (0, common_1.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 (0, xnodes_1.endorseApp)(app1Id, otherAccounts[3]); await x2EarnApps.connect(creator2).submitApp(creator2.address, creator2.address, "IntegrationApp2", "metadataURI"); app2Id = await x2EarnApps.hashAppName("IntegrationApp2"); await (0, xnodes_1.endorseApp)(app2Id, otherAccounts[4]); // Get VOT3 for citizen await (0, common_1.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 (0, common_1.bootstrapAndStartEmissions)(); // Wait one block so delegation snapshot is after emissions start await (0, common_1.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 (0, common_1.waitForNextBlock)(); } // ======================== 1. XAllocationVoting: castNavigatorVote ======================== // (0, mocha_1.describe)("castNavigatorVote on XAllocationVoting", function () { let roundId; (0, mocha_1.beforeEach)(async function () { await setupFullEcosystem(); // Start a new round so the delegation snapshot captures the delegation const currentRound = await xAllocationVoting.currentRoundId(); await (0, common_1.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]); }); (0, mocha_1.it)("happy path: cast vote, verify vote counted in round totals", async function () { await xAllocationVoting.connect(relayer).castNavigatorVote(citizen.address, roundId); (0, chai_1.expect)(await xAllocationVoting.hasVoted(roundId, citizen.address)).to.be.true; const totalVotes = await xAllocationVoting.totalVotes(roundId); (0, chai_1.expect)(totalVotes).to.equal(DELEGATE_AMOUNT); }); (0, mocha_1.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); (0, chai_1.expect)(totalVotes).to.equal(hardhat_1.ethers.parseEther("500")); // Citizen has 1000 VOT3 total const citizenBalance = await vot3.balanceOf(citizen.address); (0, chai_1.expect)(citizenBalance).to.equal(hardhat_1.ethers.parseEther("1000")); }); (0, mocha_1.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); (0, chai_1.expect)(app1Votes).to.equal(hardhat_1.ethers.parseEther("300")); // 500 * 4000/10000 = 200 const app2Votes = await xAllocationVoting.getAppVotes(roundId, app2Id); (0, chai_1.expect)(app2Votes).to.equal(hardhat_1.ethers.parseEther("200")); // Total = 300 + 200 = 500 const totalVotes = await xAllocationVoting.totalVotes(roundId); (0, chai_1.expect)(totalVotes).to.equal(hardhat_1.ethers.parseEther("500")); }); (0, mocha_1.it)("reverts NotDelegatedToNavigator when citizen is not delegated", async function () { const nonDelegated = otherAccounts[13]; await (0, common_1.getVot3Tokens)(nonDelegated, "1000"); await veBetterPassport.whitelist(nonDelegated.address); await (0, chai_1.expect)(xAllocationVoting.connect(relayer).castNavigatorVote(nonDelegated.address, roundId)).to.be.revertedWithCustomError(xAllocationVoting, "NotDelegatedToNavigator"); }); (0, mocha_1.it)("skips when navigator has no preferences (after skip window)", async function () { // Start another round where navigator hasn't set preferences await (0, common_1.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 hardhat_1.ethers.provider.getBlockNumber()); const blocksToMine = deadline - currentBlock - skipWindow; if (blocksToMine > 0n) await (0, common_1.moveBlocks)(Number(blocksToMine)); await (0, chai_1.expect)(xAllocationVoting.connect(relayer).castNavigatorVote(citizen.address, newRoundId)).to.emit(xAllocationVoting, "NavigatorVoteSkipped"); }); (0, mocha_1.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); (0, chai_1.expect)(weightedAfter).to.be.gt(weightedBefore); }); }); // ======================== 2. B3TRGovernor: castNavigatorVote ======================== // (0, mocha_1.describe)("castNavigatorVote on B3TRGovernor", function () { let roundId; let proposalId; (0, mocha_1.beforeEach)(async function () { await setupFullEcosystem(); // Advance to a new round so delegation snapshot is captured const currentRound = await xAllocationVoting.currentRoundId(); await (0, common_1.waitForRoundToEnd)(Number(currentRound)); await emissions.distribute(); roundId = await xAllocationVoting.currentRoundId(); // Give owner VOT3 to create proposals (needs enough for deposit) await (0, common_1.getVot3Tokens)(owner, "300000"); await (0, common_1.waitForNextBlock)(); // Create a proposal const tx = await (0, common_1.createProposal)(b3tr, await hardhat_1.ethers.getContractFactory("B3TR"), owner, "Nav integration test"); proposalId = await (0, common_1.getProposalIdFromTx)(tx); // Pay deposit and wait for proposal to become active await (0, common_1.payDeposit)(proposalId.toString(), owner); await (0, common_1.waitForProposalToBeActive)(proposalId); // Navigator sets decision: 2 = For (stored as 1-indexed: 1=Against, 2=For, 3=Abstain) await navigatorRegistry.connect(navigator).setProposalDecision(proposalId, 2); }); (0, mocha_1.it)("happy path: cast navigator vote, verify vote counted as For", async function () { await governor.connect(relayer).castNavigatorVote(proposalId, citizen.address); (0, chai_1.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) (0, chai_1.expect)(forVotes).to.be.gt(0n); }); (0, mocha_1.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_1.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"); (0, chai_1.expect)(event).to.not.be.undefined; // weight arg (index 4) should equal DELEGATE_AMOUNT (0, chai_1.expect)(event?.args[4]).to.equal(DELEGATE_AMOUNT); }); (0, mocha_1.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 (0, common_1.createProposal)(b3tr, await hardhat_1.ethers.getContractFactory("B3TR"), owner, "No decision proposal", "tokenDetails", [], nextRound.toString()); const newProposalId = await (0, common_1.getProposalIdFromTx)(tx2); await (0, common_1.payDeposit)(newProposalId.toString(), owner); await (0, common_1.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 hardhat_1.ethers.provider.getBlockNumber()); const blocksToMine = deadline - currentBlock - skipWindow; if (blocksToMine > 0n) await (0, common_1.moveBlocks)(Number(blocksToMine)); const tx3 = await governor.connect(relayer).castNavigatorVote(newProposalId, citizen.address); const receipt = await tx3.wait(); const libraryInterface = GovernorVotesLogic__factory_1.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"); (0, chai_1.expect)(event).to.not.be.undefined; }); (0, mocha_1.it)("reverts NotDelegatedToNavigator when citizen is not delegated", async function () { const nonDelegated = otherAccounts[14]; await (0, chai_1.expect)(governor.connect(relayer).castNavigatorVote(proposalId, nonDelegated.address)).to.be.revertedWithCustomError(governorVotesLogicLib, "NotDelegatedToNavigator"); }); (0, mocha_1.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); (0, chai_1.expect)(weightedAfter - weightedBefore).to.equal(await relayerRewardsPool.getVoteWeight()); }); }); (0, mocha_1.describe)("Relayer expected actions with navigator citizens", function () { (0, mocha_1.it)("startNewRound includes delegated citizens and active governance proposals", async function () { await setupFullEcosystem(); await (0, common_1.getVot3Tokens)(owner, "300000"); await (0, common_1.waitForNextBlock)(); const currentRound = await xAllocationVoting.currentRoundId(); const targetRound = currentRound + 1n; const createTx = await (0, common_1.createProposal)(b3tr, await hardhat_1.ethers.getContractFactory("B3TR"), owner, "Expected actions proposal", "tokenDetails", [], targetRound.toString()); const proposalId = await (0, common_1.getProposalIdFromTx)(createTx); await (0, common_1.payDeposit)(proposalId.toString(), owner); await (0, common_1.waitForRoundToEnd)(Number(currentRound)); await emissions.distribute(); const newRoundId = await xAllocationVoting.currentRoundId(); await (0, common_1.waitForProposalToBeActive)(proposalId); (0, chai_1.expect)(newRoundId).to.equal(targetRound); (0, chai_1.expect)(await governor.getActiveProposals()).to.deep.equal([proposalId]); (0, chai_1.expect)(await relayerRewardsPool.totalActions(newRoundId)).to.equal(3); (0, chai_1.expect)(await relayerRewardsPool.totalWeightedActions(newRoundId)).to.equal(7); }); (0, mocha_1.it)("castNavigatorVote skips when navigator has no preferences (after skip window)", async function () { await setupFullEcosystem(); const currentRound = await xAllocationVoting.currentRoundId(); await (0, common_1.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 hardhat_1.ethers.provider.getBlockNumber()); const blocksToMine = deadline - currentBlock - skipWindow; if (blocksToMine > 0n) await (0, common_1.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 (0, chai_1.expect)(await relayerRewardsPool.totalActions(roundId)).to.equal(totalActionsBefore - 2n); (0, chai_1.expect)(await relayerRewardsPool.totalWeightedActions(roundId)).to.equal(totalWeightedBefore - voteWeight - claimWeight); }); (0, mocha_1.it)("castNavigatorVote with no preferences skips after advancing past skip window", async function () { await setupFullEcosystem(); const currentRound = await xAllocationVoting.currentRoundId(); await (0, common_1.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 hardhat_1.ethers.provider.getBlockNumber()); const blocksToMine = deadline - currentBlock - skipWindow; if (blocksToMine > 0n) await (0, common_1.moveBlocks)(Number(blocksToMine)); await (0, chai_1.expect)(xAllocationVoting.connect(relayer).castNavigatorVote(citizen.address, roundId)).to.emit(xAllocationVoting, "NavigatorVoteSkipped"); }); (0, mocha_1.it)("castNavigatorVote skips immediately when navigator is dead", async function () { await setupFullEcosystem(); await (0, common_1.getVot3Tokens)(owner, "300000"); await (0, common_1.waitForNextBlock)(); const currentRound = await xAllocationVoting.currentRoundId(); const targetRound = currentRound + 1n; const createTx = await (0, common_1.createProposal)(b3tr, await hardhat_1.ethers.getContractFactory("B3TR"), owner, "Dead navigator proposal", "tokenDetails", [], targetRound.toString()); const proposalId = await (0, common_1.getProposalIdFromTx)(createTx); await (0, common_1.payDeposit)(proposalId.toString(), owner); await (0, common_1.waitForRoundToEnd)(Number(currentRound)); await emissions.distribute(); const roundId = await xAllocationVoting.currentRoundId(); await (0, common_1.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 (0, chai_1.expect)(await relayerRewardsPool.totalActions(roundId)).to.equal(totalActionsBefore - 3n); }); (0, mocha_1.it)("governance castNavigatorVote skips when navigator has no decision", async function () { await setupFullEcosystem(); await (0, common_1.getVot3Tokens)(owner, "300000"); await (0, common_1.waitForNextBlock)(); const currentRound = await xAllocationVoting.currentRoundId(); const targetRound = currentRound + 1n; const createTx = await (0, common_1.createProposal)(b3tr, await hardhat_1.ethers.getContractFactory("B3TR"), owner, "No decision proposal", "tokenDetails", [], targetRound.toString()); const proposalId = await (0, common_1.getProposalIdFromTx)(createTx); await (0, common_1.payDeposit)(proposalId.toString(), owner); await (0, common_1.waitForRoundToEnd)(Number(currentRound)); await emissions.distribute(); const roundId = await xAllocationVoting.currentRoundId(); await (0, common_1.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 hardhat_1.ethers.provider.getBlockNumber()); const blocksToMine = deadline - currentBlock - skipWindow; if (blocksToMine > 0n) await (0, common_1.moveBlocks)(Number(blocksToMine)); await governor.connect(relayer).castNavigatorVote(proposalId, citizen.address); (0, chai_1.expect)(await relayerRewardsPool.totalActions(roundId)).to.equal(totalActionsBefore - 1n); }); }); // ======================== Passport validation for castNavigatorVote ======================== // (0, mocha_1.describe)("castNavigatorVote passport validation", function () { (0, mocha_1.it)("XAllocationVoting: skips vote when citizen has no valid passport", async function () { await setupFullEcosystem(); const currentRound = await xAllocationVoting.currentRoundId(); await (0, common_1.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 (0, chai_1.expect)(xAllocationVoting.connect(relayer).castNavigatorVote(citizen.address, roundId)).to.emit(xAllocationVoting, "NavigatorVoteSkipped"); // Citizen should NOT have voted (0, chai_1.expect)(await xAllocationVoting.hasVoted(roundId, citizen.address)).to.be.false; // Expected actions reduced (vote + auto-claim) (0, chai_1.expect)(await relayerRewardsPool.totalActions(roundId)).to.be.lt(totalActionsBefore); }); (0, mocha_1.it)("B3TRGovernor: skips vote when citizen has no valid passport", async function () { await setupFullEcosystem(); const currentRound = await xAllocationVoting.currentRoundId(); await (0, common_1.waitForRoundToEnd)(Number(currentRound)); await emissions.distribute(); const roundId = await xAllocationVoting.currentRoundId(); await (0, common_1.getVot3Tokens)(owner, "300000"); await (0, common_1.waitForNextBlock)(); const tx = await (0, common_1.createProposal)(b3tr, await hardhat_1.ethers.getContractFactory("B3TR"), owner, "Passport test proposal"); const proposalId = await (0, common_1.getProposalIdFromTx)(tx); await (0, common_1.payDeposit)(proposalId.toString(), owner); await (0, common_1.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_1.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"); (0, chai_1.expect)(event).to.not.be.undefined; // Citizen should NOT have voted (0, chai_1.expect)(await governor.hasVoted(proposalId, citizen.address)).to.be.false; }); (0, mocha_1.it)("relayer is not penalized when citizen passport is invalid", async function () { await setupFullEcosystem(); const currentRound = await xAllocationVoting.currentRoundId(); await (0, common_1.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 (0, chai_1.expect)(xAllocationVoting.connect(relayer).castNavigatorVote(citizen.address, roundId)).to.not.be.reverted; }); (0, mocha_1.it)("citizen with valid passport can still vote normally via navigator", async function () { await setupFullEcosystem(); const currentRound = await xAllocationVoting.currentRoundId(); await (0, common_1.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); (0, chai_1.expect)(await xAllocationVoting.hasVoted(roundId, citizen.address)).to.be.true; (0, chai_1.expect)(await xAllocationVoting.totalVotes(roundId)).to.equal(DELEGATE_AMOUNT); }); (0, mocha_1.it)("XAllocationVoting: non-person citizen reverts before skip window when navigator has no preferences", async function () { await setupFullEcosystem(); const currentRound = await xAllocationVoting.currentRoundId(); await (0, common_1.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 (0, chai_1.expect)(xAllocationVoting.connect(relayer).castNavigatorVote(citizen.address, roundId)).to.be.revertedWithCustomError(xAllocationVoting, "SkipWindowNotReached"); }); (0, mocha_1.it)("XAllocationVoting: non-person citizen skips after skip window when navigator has no preferences", async function () { await setupFullEcosystem(); const currentRound = await xAllocationVoting.currentRoundId(); await (0, common_1.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 hardhat_1.ethers.provider.getBlockNumber()); const blocksToMine = deadline - currentBlock - skipWindow; if (blocksToMine > 0n) await (0, common_1.moveBlocks)(Number(blocksToMine)); // After skip window → should skip (navigator failed to act, personhood irrelevant) await (0, chai_1.expect)(xAllocationVoting.connect(relayer).castNavigatorVote(citizen.address, roundId)).to.emit(xAllocationVoting, "NavigatorVoteSkipped"); }); (0, mocha_1.it)("B3TRGovernor: non-person citizen reverts before skip window when navigator has no decision", async function () { await setupFullEcosystem(); const currentRound = await xAllocationVoting.currentRoundId(); await (0, common_1.waitForRoundToEnd)(Number(currentRound)); await emissions.distribute(); await (0, common_1.getVot3Tokens)(owner, "300000"); await (0, common_1.waitForNextBlock)(); const tx = await (0, common_1.createProposal)(b3tr, await hardhat_1.ethers.getContractFactory("B3TR"), owner, "Non-person no-decision proposal"); const proposalId = await (0, common_1.getProposalIdFromTx)(tx); await (0, common_1.payDeposit)(proposalId.toString(), owner); await (0, common_1.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 (0, chai_1.expect)(governor.connect(relayer).castNavigatorVote(proposalId, citizen.address)).to.be.revertedWithCustomError(governorVotesLogicLib, "GovernanceSkipWindowNotReached"); }); (0, mocha_1.it)("B3TRGovernor: non-person citizen skips after skip window when navigator has no decision", async function () { await setupFullEcosystem(); const currentRound = await xAllocationVoting.currentRoundId(); await (0, common_1.waitForRoundToEnd)(Number(currentRound)); await emissions.distribute(); const roundId = await xAllocationVoting.currentRoundId(); await (0, common_1.getVot3Tokens)(owner, "300000"); await (0, common_1.waitForNextBlock)(); const tx = await (0, common_1.createProposal)(b3tr, await hardhat_1.ethers.getContractFactory("B3TR"), owner, "Non-person skip-window proposal"); const proposalId = await (0, common_1.getProposalIdFromTx)(tx); await (0, common_1.payDeposit)(proposalId.toString(), owner); await (0, common_1.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 hardhat_1.ethers.provider.getBlockNumber()); const blocksToMine = deadline - currentBlock - skipWindow; if (blocksToMine > 0n) await (0, common_1.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_1.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"); (0, chai_1.expect)(event).to.not.be.undefined; (0, chai_1.expect)(await governor.hasVoted(proposalId, citizen.address)).to.be.false; }); }); // ======================== 3. VoterRewards: fee deduction ======================== // (0, mocha_1.describe)("VoterRewards fee deduction", function () { let roundId; (0, mocha_1.beforeEach)(async function () { await setupFullEcosystem(); // Advance to a new round const currentRound = await xAllocationVoting.currentRoundId(); await (0, common_1.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 (0, common_1.waitForRoundToEnd)(Number(roundId)); await emissions.distribute(); }); (0, mocha_1.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; (0, chai_1.expect)(grossReward).to.be.gt(0n); (0, chai_1.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; (0, chai_1.expect)(received).to.equal(netReward + netGmReward); (0, chai_1.expect)(received).to.be.lt(grossReward); }); (0, mocha_1.it)("navigator fee = feePercentage (2000 = 20%) of gross reward", async function () { const feePercentage = await navigatorRegistry.getFeePercentage(); (0, chai_1.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; (0, chai_1.expect)(navigatorFee).to.equal(expectedFee); }); (0, mocha_1.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); (0, chai_1.expect)(relayerFee).to.equal(expectedRelayerFee); }); (0, mocha_1.it)("fee deposited to NavigatorRegistry (check getRoundFee)", async function () { const feeBefore = await navigatorRegistry.getRoundFee(navigator.address, roundId); (0, chai_1.expect)(feeBefore).to.equal(0n); // Read navigator fee before claim (claim resets cycle data) const navigatorFee = await voterRewards.getNavigatorFee(roundId, citizen.address); (0, chai_1.expect)(navigatorFee).to.be.gt(0n); await voterRewards.connect(relayer).claimReward(roundId, citizen.address); const feeAfter = await navigatorRegistry.getRoundFee(navigator.address, roundId); (0, chai_1.expect)(feeAfter).to.be.gt(0n); // Should match the navigator fee amount (0, chai_1.expect)(feeAfter).to.equal(navigatorFee); }); (0, mocha_1.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; (0, chai_1.expect)(navigatorFee).to.equal(expected); // Verify it's not zero (citizen did vote and has rewards) (0, chai_1.expect)(navigatorFee).to.be.gt(0n); }); }); // ======================== Snapshot-consistent delegation ======================== // (0, mocha_1.describe)("snapshot-consistent delegation", function () { (0, mocha_1.beforeEach)(async function () { await setupFullEcosystem(); }); (0, mocha_1.it)("mid-round delegation: getVotes unchanged until next round", async function () { const newCitizen = otherAccounts[13]; await (0, common_1.getVot3Tokens)(newCitizen, "1000"); await veBetterPassport.whitelist(newCitizen.address); await (0, common_1.waitForNextBlock)(); // Advance to a new round const currentRound = await xAllocationVoting.currentRoundId(); await (0, common_1.waitForRoundToEnd)(Number(currentRound)); await emissions.distribute(); const roundId = await xAllocationVoting.currentRoundId(); await (0, common_1.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); (0, chai_1.expect)(votesBefore).to.equal(hardhat_1.ethers.parseEther("1000")); // Delegate mid-round await navigatorRegistry.connect(newCitizen).delegate(navigator.address, hardhat_1.ethers.parseEther("500")); await (0, common_1.waitForNextBlock)(); // getVotes at round snapshot is STILL full balance (snapshot is before delegation) const votesAfter = await xAllocationVoting.getVotes(newCitizen.address, snapshot); (0, chai_1.expect)(votesAfter).to.equal(hardhat_1.ethers.parseEther("1000")); }); (0, mocha_1.it)("mid-round undelegate: citizen blocked from manual vote this round", async function () { // Advance to a new round const currentRound = await xAllocationVoting.currentRoundId(); await (0, common_1.waitForRoundToEnd)(Number(currentRound)); await emissions.distribute(); const roundId = await xAllocationVoting.currentRoundId(); await (0, common_1.waitForNextBlock)(); // citizen was delegated before round start, so snapshot shows delegation // Undelegate mid-round await navigatorRegistry.connect(citizen).undelegate(); await (0, common_1.waitForNextBlock)(); // Manual vote should still be blocked (was delegated at snapshot, navigator alive) await (0, chai_1.expect)(xAllocationVoting.connect(citizen).castVote(roundId, [app1Id], [hardhat_1.ethers.parseEther("500")])).to.be.revertedWithCustomError(xAllocationVoting, "DelegatedToNavigator"); }); (0, mocha_1.it)("castNavigatorVote uses snapshot navigator, not current", async function () { // Advance to a new round const currentRound = await xAllocationVoting.currentRoundId(); await (0, common_1.waitForRoundToEnd)(Number(currentRound)); await emissions.distribute(); const roundId = await xAllocationVoting.currentRoundId(); await (0, common_1.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 (0, common_1.waitForNextBlock)(); // castNavigatorVote should still work (uses snapshot navigator) await xAllocationVoting.connect(relayer).castNavigatorVote(citizen.address, roundId); (0, chai_1.expect)(await xAllocationVoting.hasVoted(roundId, citizen.address)).to.be.true; }); (0, mocha_1.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 (0, common_1.waitForRoundToEnd)(Number(currentRound)); await emissions.distribute(); await (0, common_1.waitForNextBlock)(); const block = await hardhat_1.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); (0, chai_1.expect)(govVotes).to.equal(hardhat_1.ethers.parseEther(CITIZEN_VOT3)); (0, chai_1.expect)(xAllocVotes).to.equal(hardhat_1.ethers.parseEther(CITIZEN_VOT3)); }); (0, mocha_1.it)("getVotes returns delegated amount at timepoint before navigator death", async function () { const blockBeforeDeath = await hardhat_1.ethers.provider.getBlockNumber(); await (0, common_1.waitForNextBlock)(); // Deactivate the navigator await navigatorRegistry.deactivateNavigator(navigator.address, 0, false); await (0, common_1.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); (0, chai_1.expect)(govVotes).to.equal(DELEGATE_AMOUNT); (0, chai_1.expect)(xAllocVotes).to.equal(DELEGATE_AMOUNT); }); (0, mocha_1.it)("double-vote prevention: delegated at snapshot blocks castVote", async function () { // Advance to a new round const currentRound = await xAllocationVoting.currentRoundId(); await (0, common_1.waitForRoundToEnd)(Number(currentRound)); await emissions.distribute(); const roundId = await xAllocationVoting.currentRoundId(); await (0, common_1.waitForNextBlock)(); // citizen was delegated at snapshot => manual vote should be blocked await (0, chai_1.expect)(xAllocationVoting.connect(citizen).castVote(roundId, [app1Id], [hardhat_1.ethers.parseEther("500")])).to.be.revertedWithCustomError(xAllocationVoting, "DelegatedToNavigator"); }); (0, mocha_1.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 (0, common_1.waitForRoundToEnd)(Number(currentRound)); await emissions.distribute(); const roundId = await xAllocationVoting.currentRoundId(); await (0, common_1.waitForNextBlock)(); // Manual vote should be allowed (navigator was dead at snapshot) await xAllocationVoting.connect(citizen).castVote(roundId, [app1Id], [hardhat_1.ethers.parseEther("500")]); (0, chai_1.expect)(await xAllocationVoting.hasVoted(roundId, citizen.address)).to.be.true; }); (0, mocha_1.it)("getNavigatorAtTimepoint returns correct navigator at past block", async function () { const block = await hardhat_1.ethers.provider.getBlockNumber(); const timepoint = block - 1; const navAtTimepoint = await navigatorRegistry.getNavigatorAtTimepoint(citizen.address, timepoint); (0, chai_1.expect)(navAtTimepoint).to.equal(navigator.address); }); (0, mocha_1.it)("getNavi