Voting & Delisting
IndexPVP governance is a secret roundtable of index leaders. Only the top holder of each listed index gets a seat, every seat's vote is weighted by the index's market-cap rank, the blue-chip #1 index is immune from delisting, 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 listed index. If the Nikkei 225 has 12,000 holders, only the single wallet holding the most N225 tokens gets a vote. That person is the "leader" of Japan's index.
Governance is a roundtable of leaders — one seat per listed index. The table starts full (every index in the game has a seat) and shrinks one seat per delisting.
How much does each vote count?
Each leader's vote is weighted by their index's market-cap rank, scaled by how many indexes are still listed that round:
Formula: weight = listedCount - rank + 1
So if there are N listed indexes, the #1 index's vote is worth N, the #2 index's is worth N-1, and the last-place index's is worth 1.
| Rank | Weight |
|---|---|
| #1 (top market cap) | listedCount — immune (cannot be targeted), but still casts the heaviest vote |
| #2 | listedCount - 1 |
| #3 | listedCount - 2 |
| ... | ... |
| Last place | 1 |
Because the weight is pegged to listedCount — not to a fixed 150 or 117 — the #1 seat's power scales with the game. Early in the game every seat matters a little; in the endgame with 10 indexes left, the #1 seat is worth 10 and the last seat is worth 1.
The top index gets the most voting power and cannot be the target — being the blue chip 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 listed index by market cap, looks up each index'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 index and submits an encrypted vote. The on-chain transaction reveals:
- Nothing about which index you lead
- Nothing about who you voted against
- Only that some index'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 index voted against which.
- Most-voted index is delisted.
executeDelisting()requests randomness from Quiver VRF (viaQuiverRandomnessProvider), pulls liquidity, splits the recovered ETH 40/40/10/10 across winner/spread/random/treasury, and activates the circuit breaker 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 index dictate every outcome. Rank-weighted, one-seat-per-index 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 listed index, a blue Secret Vote panel appears. It shows:
- Which index you lead and your current rank / weight
- A grid of valid targets (every listed index except the immune blue chip)
- 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 index 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; the circuit's input names use country_code — the ISO alpha-2 country code that keys each index):
voter_address— the leader's ETH addresscountry_code— the bytes2 code keying the index they lead (e.g.0x4a50= JP for the Nikkei 225)rank— the index'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 listed indexesnullifier = Poseidon(2, round_id, country_code)— per-round-per-index 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 listed index at some rank. - The published
nullifieris correctly derived from that index's code and the round ID — so the same index 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 index 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 the EIP-170 contract-size limit.
2. GovernanceVotingV2.sol
Source: packages/contracts/src/GovernanceVotingV2.sol.
Round lifecycle:
triggerVote() on DelistGame
↓
DelistGame.governance.startVoteRound(immuneIndex)
↓ emits VoteStarted(roundId, immuneIndex, 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, targetIndex)
executeDelisting(roundId)
↓ calls governance.finalize(roundId) which returns cached targetIndex
↓ requests Quiver VRF randomness → delisting flow (buybacks, liquidity removal, etc.)
Key on-chain state:
| Storage | Purpose |
|---|---|
VoteRoundV2[roundId] | immuneIndex, rankingRoot, aliveCount, voteStart, voteEnd, finalized, targetIndex, candidates[] |
nullifierUsed[roundId][nullifier] | Prevents double voting (one vote per index per round) |
votes[roundId][voteIndex] | { nullifier, weight, voteCommitment, encryptedVote } per cast vote |
votesFor[roundId][indexCode] | 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(voterIndex, targetIndex, 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 blue-chip index.
- Tallies weighted votes and emits the final
targetIndex.
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 listed indexes + market caps from
DelistGamevia multicall. - Fetch each index's top holder from Ponder's
/top-holdersendpoint (excluding the PoolManager and DelistGame 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(indexCode, rank, topHolder)leaves. - Call
setRankingRoot(roundId, root, aliveCount)onGovernanceVotingV2. - Serve the full ranking + Merkle proofs from
GET /rankingso voters can generate proofs.
- Read listed indexes + 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
executeDelistingcan proceed.
- Read all
Randomness is not a keeper duty. The IndexPVP keeper does not commit, reveal, or otherwise handle randomness. When executeDelisting requests randomness, it goes to Quiver VRF (via QuiverRandomnessProvider), and Quiver's own keeper — "Fletcher" — delivers the reveal callback. See The Game Cycle for details.
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 delisting target (the contract tallies based on revealed targets).
- It cannot grind the random buyback targets — the keeper plays no part in randomness; that comes from Quiver VRF, whose two-party value is unbiasable as long as either the game or Quiver is honest.
What the keeper can do — and this is the trust assumption — is refuse to reveal. If the keeper goes offline, the round stalls until the timeout fallback. 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 (contains the index's code,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(voterIndex, targetIndex, salt)). - Packs
{ voterIndex, targetIndex, 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 (indexCode, rank, topHolder) membership |
| Only the blue-chip index is safe | Cryptographic | Contract rejects reveals targeting immuneIndex |
| Votes are weighted by rank | Cryptographic | Circuit enforces weight = aliveCount - rank + 1 |
| No double voting | Cryptographic | Nullifier Poseidon(2, roundId, indexCode) is unique per round per index |
| 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 |
| Buyback randomness cannot be ground | Cryptographic | Quiver VRF two-party value keccak256(userRandom ‖ providerRevelation); the IndexPVP keeper is not involved |
| 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 | Robinhood Chain (4663) | Robinhood Chain Testnet (46630) |
|---|---|---|
DelistGame | TBD | TBD |
GovernanceVotingV2 | TBD | TBD |
HonkVerifier (ZK) | TBD | TBD |
After voting closes the keeper reveals; then the delisting itself fires via Quiver VRF, splits recovered ETH 40/40/10/10 across winner/spread/random/treasury, and activates the circuit breaker on buyback recipients. See the sections below for full details.
The Delisting
Two-Phase Execution (Quiver VRF Randomness)
Delisting execution is split into two on-chain transactions to ensure tamper-resistant randomness from Quiver VRF (Chainlink VRF is not available on Robinhood Chain):
Phase 1 — Request (instant). When someone calls executeDelisting(), the contract reads the already-finalized target from GovernanceVotingV2, stores the pending delisting state, and sends a randomness request through QuiverRandomnessProvider to the Quiver coordinator. The Trading Halt (20% sell fee) remains active.
Phase 2 — Reveal callback (moments later). Quiver's own keeper, "Fletcher", delivers the reveal callback, and the verified random word — keccak256(userRandom ‖ providerRevelation), a two-party value unbiasable as long as either the game or Quiver is honest — reaches the game via rawFulfillRandomness. The callback uses this randomness to select buyback targets, removes liquidity, distributes ETH, and completes the delisting. The IndexPVP keeper plays no part in this step.
If the randomness callback does not arrive within 1 hour (RANDOMNESS_TIMEOUT), the contract owner can call cancelPendingDelisting(), which executes the delisting using fallback entropy keccak256(blockhash(block.number - 1), block.timestamp, ...) as a last resort. This liveness safety net applies regardless of which provider is active and ensures finalized governance votes are never discarded — the delisting always completes. (block.prevrandao is never used: it is the constant 1 on Arbitrum-stack chains like Robinhood Chain.)
What Happens to the Delisted Index?
- Liquidity removal — All 7 liquidity positions are pulled from the delisted index's Uniswap pool.
- Ticker pulled — The index's token is marked as
delisted. 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 delisted pool is split into four parts:
- 40% Winner Buyback — Swap into the blue-chip #1 index's tokens.
- 40% Spread Buyback — Swap into a random listed index (never the winner).
- 10% Random Buyback — Swap into another random listed index (never the winner or the spread target when 3+ indexes remain).
- 10% Treasury — Funds the PrizePot and protocol operations.
Circuit Breaker
Each buyback swap triggers a circuit breaker 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 Delisting
- The delisted index is removed from the listed set.
- A new 4-hour countdown begins.
- The Trading Halt deactivates (20% sell fee removed).
- Normal trading resumes (except circuit breakers on buyback recipients).
The Endgame Dynamic
As indexes are delisted, weights and stakes compress:
- Fewer seats at the table — With 10 indexes 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 index (fewer to split between).
- Coalition politics — Because votes are secret until reveal, alliances can only be verified after the round ends. A leader who promised to vote against the Nikkei can still have voted against the CAC 40, and nobody knows until reveal.
- Prize Pot grows — Every trade across every index feeds the final reward.
The Final Delisting
When the second-to-last index is delisted, the market closes:
- The surviving index is declared the winner — the last ticker standing.
- The Prize Pot is finalized — snapshotting the accumulated ETH from 30% of all trading fees.
- The winning index's token holders can redeem their share at NAV by forfeiting tokens for proportional ETH (the RFV).
- No more countdowns, votes, or delistings occur.
See Prize Pot & RFV for full redemption details.