@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
JavaScript
"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