Voting & Nuking
GlobalPVP governance is a secret roundtable of country leaders. Only the top holder of each alive country gets a seat, every seat's vote is weighted by the country's market-cap rank, the #1 country is immune from being nuked, and no one sees who voted for whom until the round ends.
This page is split into two parts:
- Part 1 — High-Level Overview — what happens from a player's perspective.
- Part 2 — Low-Level Overview — the cryptography, contracts, and keeper that make it work.
Part 1 — High-Level Overview
Who gets to vote?
Only the top holder of each alive country. If Japan has 12,000 holders, only the single wallet holding the most JP tokens gets a vote. That person is the "leader" of Japan.
Governance is a roundtable of leaders — one seat per alive country. The table starts full (every country in the game has a seat) and shrinks one seat per nuke.
How much does each vote count?
Each leader's vote is weighted by their country's market-cap rank, scaled by how many countries are still alive that round:
Formula: weight = aliveCount - rank + 1
So if there are N alive countries, the #1 country's vote is worth N, the #2 country's is worth N-1, and the last-place country's is worth 1.
| Rank | Weight |
|---|---|
| #1 (top market cap) | aliveCount — immune (cannot be targeted), but still casts the heaviest vote |
| #2 | aliveCount - 1 |
| #3 | aliveCount - 2 |
| ... | ... |
| Last place | 1 |
Because the weight is pegged to aliveCount — not to a fixed 150 or 177 — the #1 seat's power scales with the game. Early in the game every seat matters a little; in the endgame with 10 countries left, the #1 seat is worth 10 and the last seat is worth 1.
The top country gets the most voting power and cannot be the target — being #1 is genuinely the safest spot.
What does a round look like?
- Countdown expires. Anyone can call
triggerVote()to open a round. - Keeper snapshots the ranking. A backend bot sorts every alive country by market cap, looks up each country's top holder, and publishes the snapshot on-chain as a cryptographic commitment.
- Leaders cast secret votes (5-minute window). Each leader picks a target country and submits an encrypted vote. The on-chain transaction reveals:
- Nothing about which country you lead
- Nothing about who you voted against
- Only that some country's leader cast a vote for some target
- Voting window closes. Nobody has learned anything about who voted for whom.
- Keeper reveals all votes in one batch transaction. The full roundtable results are now public — everyone can see exactly which country voted against which.
- Most-voted country is nuked.
executeNuke()fires Chainlink VRF for randomness, pulls liquidity, splits the recovered ETH 40/40/10/10 across winner/spread/random/treasury, and activates anti-snipe protection on buyback recipients.
Why this design?
Two failure modes are avoided by design:
- Whale dominance. Token-weighted voting would let whoever held the most of the winning country dictate every outcome. Rank-weighted, one-seat-per-country voting spreads power across the ecosystem instead of concentrating it in a single whale.
- Public vote broadcasting. If votes streamed in live, players would bandwagon and front-run kingmaker votes. Encrypted ballots force every leader to commit to their target blindly; the full roundtable is only revealed once the window closes.
What does a player see in the UI?
When a round is active and you are the top holder of any alive country, a blue Secret Vote panel appears. It shows:
- Which country you lead and your current rank / weight
- A grid of valid targets (every alive country except the immune #1)
- A "Cast Secret Vote" button
Click the button, wait a few seconds while your browser generates a zero-knowledge proof, then sign the transaction. Done — your target stays secret until the window closes.
If you are not a top holder, the panel tells you so. You can still trade country tokens and climb the leaderboard — becoming a top holder is how you earn a seat.
Part 2 — Low-Level Overview
The secret-voting system is built from four coordinated pieces:
- A Noir ZK circuit that proves vote eligibility without revealing identity
- A
GovernanceVotingV2contract that verifies proofs and stores encrypted votes - A keeper service that snapshots rankings and decrypts votes after the window
- A Web Worker in the frontend that generates proofs and encrypts votes in the browser
1. The ZK Circuit (vote_eligibility)
Source: packages/circuits/vote_eligibility/src/main.nr. Written in Noir, proved with Barretenberg's UltraHonk backend.
Private inputs (known only to the voter's browser):
voter_address— the leader's ETH addresscountry_code— the bytes2 code of the country they lead (e.g.0x4a50for Japan)rank— the country's market-cap rank (1-indexed)merkle_path[8]+merkle_indices[8]— Merkle inclusion proof thatPoseidon(country_code, rank, voter_address)is a leaf of the keeper's published ranking tree
Public inputs (visible on-chain):
ranking_root— the keeper's published Merkle rootround_id— the current voting roundalive_count— number of alive countriesnullifier = Poseidon(2, round_id, country_code)— per-round-per-country identifier, used to prevent double votingweight = alive_count - rank + 1
What the circuit proves in zero-knowledge:
- The prover knows a
(country_code, rank, voter_address)triple that hashes to a leaf of theranking_rootMerkle tree — i.e. the prover really is the top holder of some alive country at some rank. - The published
nullifieris correctly derived from that country's code and the round ID — so the same country cannot vote twice in the same round. weight = alive_count - rank + 1— the declared weight matches the declared rank.
The verifier learns the nullifier and weight but not which country the prover leads. This is the core privacy property.
The verifier contract is generated with bb write_solidity_verifier --verifier_target evm-no-zk --optimized and compiled standalone (not with via-IR) so its deployed bytecode (~14.8KB) fits under Base Sepolia's EIP-170 contract-size limit.
2. GovernanceVotingV2.sol
Source: packages/contracts/src/GovernanceVotingV2.sol.
Round lifecycle:
triggerVote() on NukeGame
↓
NukeGame.governance.startVoteRound(immuneCountry)
↓ emits VoteStarted(roundId, immuneCountry, voteEnd)
keeper observes event → setRankingRoot(roundId, root, aliveCount)
↓ emits RankingRootSet(roundId, root, aliveCount)
castVote(nullifier, weight, voteCommitment, encryptedVote, proof) ← each eligible leader
↓ emits VoteCast(roundId, voteIndex, nullifier, weight)
(repeated for up to aliveCount voters, 5 min window)
voting window closes
↓
keeper decrypts every encryptedVote → revealAndFinalize(roundId, reveals[])
↓ emits VoteRevealed(...) × N, then VoteFinalized(roundId, targetCountry)
executeNuke(roundId)
↓ calls governance.finalize(roundId) which returns cached targetCountry
↓ requests Chainlink VRF → nuke flow (buybacks, liquidity removal, etc.)
Key on-chain state:
| Storage | Purpose |
|---|---|
VoteRoundV2[roundId] | immuneCountry, rankingRoot, aliveCount, voteStart, voteEnd, finalized, targetCountry, candidates[] |
nullifierUsed[roundId][nullifier] | Prevents double voting (one vote per country per round) |
votes[roundId][voteIndex] | { nullifier, weight, voteCommitment, encryptedVote } per cast vote |
votesFor[roundId][countryCode] | Tally — only populated during revealAndFinalize |
keeperPublicKey | NaCl box public key — frontend encrypts to this |
What castVote enforces:
verifier.verify(proof, [rankingRoot, roundId, aliveCount, nullifier, weight])passes.nullifierUsed[roundId][nullifier] == false(then sets it true).- The round exists, is within its voting window, and has a ranking root set.
What revealAndFinalize enforces:
- Only callable by the keeper.
- Voting window has ended, round not already finalized.
- Number of reveals equals
voteCount[roundId](keeper must reveal all or none). - For each reveal,
keccak256(voterCountry, targetCountry, salt) == stored voteCommitment— this is the trust-but-verify step. The keeper decrypts the ciphertext off-chain and submits the preimages; the contract checks the keccak binding commitment that was stored atcastVotetime. - No vote can target the immune country.
- Tallies weighted votes and emits the final
targetCountry.
The keeper cannot forge votes or change who voted for whom — the keccak commitment was posted at vote time, before the keeper knew any plaintext.
3. The Keeper Service
Source: packages/keeper/. Node.js process with a small HTTP server.
Responsibilities:
-
Ranking snapshot. When
VoteStartedfires:- Read alive countries + market caps from
NukeGamevia multicall. - Fetch each country's top holder from Ponder's
/top-holdersendpoint (excluding the PoolManager and NukeGame from holder calculations — those custody large balances but aren't players). - Sort by market cap descending, assign ranks.
- Build a Poseidon Merkle tree of
Poseidon(countryCode, rank, topHolder)leaves. - Call
setRankingRoot(roundId, root, aliveCount)onGovernanceVotingV2. - Serve the full ranking + Merkle proofs from
GET /rankingso voters can generate proofs.
- Read alive countries + market caps from
-
Vote decryption. When the voting window closes:
- Read all
VoteCastevents for the round. - For each ciphertext, decrypt with the keeper's NaCl box secret key.
- Build the
RevealData[]array and submitrevealAndFinalize(roundId, reveals). - If no votes were cast, submit an empty array — this is still required so
executeNukecan proceed.
- Read all
Keys the keeper holds:
- Ethereum wallet key — signs
setRankingRootandrevealAndFinalizetransactions. - NaCl box secret key — decrypts vote ciphertexts. The corresponding public key is stored on-chain in
GovernanceVotingV2.keeperPublicKey; the frontend reads it and encrypts every vote to it.
The keeper is not trusted to decide the outcome:
- It cannot tamper with a vote (keccak commitments are posted before the keeper sees plaintext).
- It cannot invent fake votes (every reveal must match an on-chain
voteCommitment). - It cannot choose the nuke target (the contract tallies based on revealed targets).
What the keeper can do — and this is the trust assumption — is refuse to reveal. If the keeper goes offline, the round stalls. A future V3 could replace the keeper with a threshold-decryption committee to remove this liveness dependency.
4. The Frontend Prover
Source: packages/app/lib/noir/, packages/app/components/ui/VoteV2Panel.js.
Proof generation runs in a Web Worker so the WASM-heavy Barretenberg backend never touches the main page bundle.
Flow when a leader clicks "Cast Secret Vote":
- Panel fetches
GET /rankingfrom the keeper → gets the entry for the connected wallet (containscountryCode,rank,topHolder,merkleProof). - Main thread spawns the prover worker (
proverWorker.js), which dynamically imports@noir-lang/noir_js+@aztec/bb.jsand loadspublic/circuits/vote_eligibility.json. - Worker runs
noir.execute(inputs)thenbackend.generateProof(witness, { keccak: true }). Takes ~3–5 seconds. - Main thread generates a random 32-byte salt, computes
voteCommitment = keccak256(abi.encodePacked(countryCode, targetCountry, salt)). - Packs
{ voterCountry, targetCountry, voter, rank, salt }into JSON, encrypts it with NaCl box to the keeper's public key (read fromGovernanceVotingV2.keeperPublicKey()). Packed format isephemeralPubKey (32 bytes) | nonce (24 bytes) | ciphertext. - Calls
castVote(nullifier, weight, voteCommitment, encryptedVote, proof)via wagmi.
Trust Model Summary
| Property | Guarantee | Mechanism |
|---|---|---|
| Only top holders can vote | Cryptographic | Merkle proof of (country, rank, topHolder) membership |
| Only the immune country is safe | Cryptographic | Contract rejects reveals targeting immuneCountry |
| Votes are weighted by rank | Cryptographic | Circuit enforces weight = aliveCount - rank + 1 |
| No double voting | Cryptographic | Nullifier Poseidon(2, roundId, countryCode) is unique per round per country |
| Votes stay secret during the window | Cryptographic | NaCl box encryption to keeper pubkey; only ciphertext is on-chain |
| Keeper cannot tamper with revealed votes | Cryptographic | Keccak binding commitment posted at vote time |
| Round eventually finalizes | Trusted | Keeper liveness — mitigated by owner-only fallback, v3 will use threshold decryption |
| Market-cap ranking is honest | Trusted | Keeper computes the snapshot; observable via Ponder |
Relevant Contracts
| Contract | Base Mainnet (8453) | Base Sepolia (84532) |
|---|---|---|
NukeGame | 0x3d54428cc29b0db83f4a1dc4e3493fcd462b94dc | 0x7f99117285cefcdFdD99c576A477A7DDba358163 |
GovernanceVotingV2 | 0x7263cc65f1688fdcf2b412983c45012171f330c2 | 0xa682896de2018e47aD02fc9F27d3FD04f946f571 |
HonkVerifier (ZK) | not yet deployed | 0x189d3fB06Ed8B01B7b64aB81DFc24EA16a180e1C |
After voting closes the keeper reveals; then the nuke itself fires via Chainlink VRF, splits recovered ETH 40/40/10/10 across winner/spread/random/treasury, and activates anti-snipe protection on buyback recipients. See the sections below for full details.
The Nuke
Two-Phase Execution (Chainlink VRF)
Nuke execution is split into two on-chain transactions to ensure verifiable, tamper-proof randomness:
Phase 1 — Request (instant). When someone calls executeNuke(), the contract reads the already-finalized target from GovernanceVotingV2, stores the pending nuke state, and sends a randomness request to Chainlink VRF V2.5. The Nuke Window (20% sell fee) remains active.
Phase 2 — Callback (1-2 blocks later). Chainlink's VRF node delivers a cryptographically verified random number to the contract. The callback uses this randomness to select buyback targets, removes liquidity, distributes ETH, and completes the nuke.
:::note VRF Timeout Fallback
If the Chainlink VRF callback does not arrive within 1 hour (e.g. due to VRF node downtime), the contract owner can trigger a fallback that executes the nuke using block.prevrandao as a last resort. This ensures finalized governance votes are never discarded — the nuke always completes.
:::
What Happens to the Nuked Country?
- Liquidity removal — All 7 liquidity positions are pulled from the nuked country's Uniswap pool.
- Token death — The country's token is marked as
nuked. The swap router will reject any future buy or sell attempts. - Pool emptied — The Uniswap pool is effectively dead.
ETH Redistribution
The ETH recovered from the nuked pool is split into four parts:
- 40% Winner Buyback — Swap into the #1 (immune) country's tokens.
- 40% Spread Buyback — Swap into a random alive country (never the winner).
- 10% Random Buyback — Swap into another random alive country (never the winner or the spread target when 3+ countries remain).
- 10% Treasury — Funds the PrizePot and protocol operations.
Anti-Snipe Protection
Each buyback swap triggers anti-snipe protection on the receiving pool: 90% sell fee immediately after the buyback, decaying linearly to the normal base fee over 5 minutes. This prevents MEV bots from sandwiching the buyback.
After the Nuke
- The nuked country is removed from the alive list.
- A new 4-hour countdown begins.
- The Nuke Window deactivates (20% sell fee removed).
- Normal trading resumes (except anti-snipe on buyback recipients).
The Endgame Dynamic
As countries are eliminated, weights and stakes compress:
- Fewer seats at the table — With 10 countries left, the #1 leader's vote is worth 10 and the #10 leader's is worth 1. Every seat becomes valuable.
- Bigger buybacks — More ETH per surviving country (fewer to split between).
- Alliance politics — Because votes are secret until reveal, alliances can only be verified after the round ends. A leader who promised to vote against Japan can still have voted against France, and nobody knows until reveal.
- Prize Pot grows — Every trade across every country feeds the final reward.
The Final Nuke
When the second-to-last country is nuked, the game ends:
- The surviving country is declared the winner.
- The Prize Pot is finalized — snapshotting the accumulated ETH from 30% of all trading fees.
- The winning country's token holders can claim their share by forfeiting tokens for proportional ETH (the RFV).
- No more countdowns, votes, or nukes occur.
See Prize Pot & RFV for full claiming details.