Skip to main content

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

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.

RankWeight
#1 (top market cap)aliveCountimmune (cannot be targeted), but still casts the heaviest vote
#2aliveCount - 1
#3aliveCount - 2
......
Last place1

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?

  1. Countdown expires. Anyone can call triggerVote() to open a round.
  2. 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.
  3. 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
  4. Voting window closes. Nobody has learned anything about who voted for whom.
  5. Keeper reveals all votes in one batch transaction. The full roundtable results are now public — everyone can see exactly which country voted against which.
  6. 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:

  1. A Noir ZK circuit that proves vote eligibility without revealing identity
  2. A GovernanceVotingV2 contract that verifies proofs and stores encrypted votes
  3. A keeper service that snapshots rankings and decrypts votes after the window
  4. 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 address
  • country_code — the bytes2 code of the country they lead (e.g. 0x4a50 for Japan)
  • rank — the country's market-cap rank (1-indexed)
  • merkle_path[8] + merkle_indices[8] — Merkle inclusion proof that Poseidon(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 root
  • round_id — the current voting round
  • alive_count — number of alive countries
  • nullifier = Poseidon(2, round_id, country_code) — per-round-per-country identifier, used to prevent double voting
  • weight = alive_count - rank + 1

What the circuit proves in zero-knowledge:

  1. The prover knows a (country_code, rank, voter_address) triple that hashes to a leaf of the ranking_root Merkle tree — i.e. the prover really is the top holder of some alive country at some rank.
  2. The published nullifier is correctly derived from that country's code and the round ID — so the same country cannot vote twice in the same round.
  3. 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:

StoragePurpose
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
keeperPublicKeyNaCl box public key — frontend encrypts to this

What castVote enforces:

  1. verifier.verify(proof, [rankingRoot, roundId, aliveCount, nullifier, weight]) passes.
  2. nullifierUsed[roundId][nullifier] == false (then sets it true).
  3. The round exists, is within its voting window, and has a ranking root set.

What revealAndFinalize enforces:

  1. Only callable by the keeper.
  2. Voting window has ended, round not already finalized.
  3. Number of reveals equals voteCount[roundId] (keeper must reveal all or none).
  4. 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 at castVote time.
  5. No vote can target the immune country.
  6. 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:

  1. Ranking snapshot. When VoteStarted fires:

    • Read alive countries + market caps from NukeGame via multicall.
    • Fetch each country's top holder from Ponder's /top-holders endpoint (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) on GovernanceVotingV2.
    • Serve the full ranking + Merkle proofs from GET /ranking so voters can generate proofs.
  2. Vote decryption. When the voting window closes:

    • Read all VoteCast events for the round.
    • For each ciphertext, decrypt with the keeper's NaCl box secret key.
    • Build the RevealData[] array and submit revealAndFinalize(roundId, reveals).
    • If no votes were cast, submit an empty array — this is still required so executeNuke can proceed.

Keys the keeper holds:

  • Ethereum wallet key — signs setRankingRoot and revealAndFinalize transactions.
  • 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":

  1. Panel fetches GET /ranking from the keeper → gets the entry for the connected wallet (contains countryCode, rank, topHolder, merkleProof).
  2. Main thread spawns the prover worker (proverWorker.js), which dynamically imports @noir-lang/noir_js + @aztec/bb.js and loads public/circuits/vote_eligibility.json.
  3. Worker runs noir.execute(inputs) then backend.generateProof(witness, { keccak: true }). Takes ~3–5 seconds.
  4. Main thread generates a random 32-byte salt, computes voteCommitment = keccak256(abi.encodePacked(countryCode, targetCountry, salt)).
  5. Packs { voterCountry, targetCountry, voter, rank, salt } into JSON, encrypts it with NaCl box to the keeper's public key (read from GovernanceVotingV2.keeperPublicKey()). Packed format is ephemeralPubKey (32 bytes) | nonce (24 bytes) | ciphertext.
  6. Calls castVote(nullifier, weight, voteCommitment, encryptedVote, proof) via wagmi.

Trust Model Summary

PropertyGuaranteeMechanism
Only top holders can voteCryptographicMerkle proof of (country, rank, topHolder) membership
Only the immune country is safeCryptographicContract rejects reveals targeting immuneCountry
Votes are weighted by rankCryptographicCircuit enforces weight = aliveCount - rank + 1
No double votingCryptographicNullifier Poseidon(2, roundId, countryCode) is unique per round per country
Votes stay secret during the windowCryptographicNaCl box encryption to keeper pubkey; only ciphertext is on-chain
Keeper cannot tamper with revealed votesCryptographicKeccak binding commitment posted at vote time
Round eventually finalizesTrustedKeeper liveness — mitigated by owner-only fallback, v3 will use threshold decryption
Market-cap ranking is honestTrustedKeeper computes the snapshot; observable via Ponder

Relevant Contracts

ContractBase Mainnet (8453)Base Sepolia (84532)
NukeGame0x3d54428cc29b0db83f4a1dc4e3493fcd462b94dc0x7f99117285cefcdFdD99c576A477A7DDba358163
GovernanceVotingV20x7263cc65f1688fdcf2b412983c45012171f330c20xa682896de2018e47aD02fc9F27d3FD04f946f571
HonkVerifier (ZK)not yet deployed0x189d3fB06Ed8B01B7b64aB81DFc24EA16a180e1C

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

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?

  1. Liquidity removal — All 7 liquidity positions are pulled from the nuked country's Uniswap pool.
  2. Token death — The country's token is marked as nuked. The swap router will reject any future buy or sell attempts.
  3. 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:

  1. The surviving country is declared the winner.
  2. The Prize Pot is finalized — snapshotting the accumulated ETH from 30% of all trading fees.
  3. The winning country's token holders can claim their share by forfeiting tokens for proportional ETH (the RFV).
  4. No more countdowns, votes, or nukes occur.

See Prize Pot & RFV for full claiming details.