This is a submission for Weekend Challenge: Passion Edition
Somewhere out there is a developer with strong opinions about tabs versus spaces who has never once said them out loud.
You know why. The modern web made opinions expensive. Everything you say is attributable, searchable, and permanent; every hot take is a receipt someone can pull up in a job interview five years from now. So the people who care the most learned to say nothing, and the conversation got handed to the two groups who never shut up: people with nothing to lose, and bots. Every online poll you've ever seen measures some blend of brigades, botnets and apathy — precisely not the thing it claims to measure, which is conviction.
Democracy figured this out embarrassingly long before we did. The secret ballot — the actual paper-in-a-box kind — spread from Australia in 1856 (Americans literally called it "the Australian ballot") because public voting invites coercion, conformity and retaliation. Before it, elections were routinely voted out loud, in public. That worked exactly as well as you'd guess: George Washington won his first seat in 1758 after his campaign treated the district's ~400 voters to some 160 gallons of rum punch, wine and beer. People don't vote honestly (or soberly) while being watched. Anonymity in voting was never about having something to hide. It's the mechanism that makes honesty possible.
So this weekend I wanted to know what it would take to hold a real referendum on the internet's oldest religious wars — tabs vs spaces, Vim vs Emacs, dark vs light — one where votes are honest, weighted by genuine passion, impossible to fake, and impossible to trace. ("Oldest" is barely a figure of speech: Emacs and the ancestor of vi both shipped in 1976. The editor war turns fifty this year and it has never once been settled.) The answer turned out to be: a census, two zero-knowledge proofs, and a blockchain fast enough to make the scoreboard feel alive.
What I Built
HOLY WARS — The Eternal Scoreboard settles the holy wars where nobody can cheat and nobody gets doxxed: on Solana.
Think about what an honest referendum actually needs — not vibes, mechanics:
A census. A vote only means something if the set of eligible voters is defined and each appears exactly once. Without that, a poll is a screenshot of nothing. Here your GitHub account is your passport: an attestation service verifies you via OAuth and signs you into an on-chain census — one dev, one census entry per war.
Sybil resistance. On the internet, nobody knows you're forty accounts. Wallets are free; GitHub accounts with history are not. Your census weight comes from a "Proof of Passion" scanner that reads your public repos before you enlist. Two hundred thousand lines indented with spaces? Your spaces vote counts more. Your
.vimrchas archaeology in it? Vim gets a heavier ballot. You cannot fake a decade of commit history by Sunday night — passion, quantified, is the anti-bot layer.
A count nobody has to trust. This is what the blockchain is actually for. The rules are a program anyone can read, the tally lives in an account anyone can query, and nobody — including me, the person who wrote it — can quietly nudge the numbers.
A secret ballot. And here's the trap: a public ledger makes voting worse, not better. A vote recorded on a transparent chain is the least secret ballot in human history — a receipt, forever, attached to your wallet. This is why zero-knowledge proofs exist in this project. You don't send your vote from your wallet. Your browser generates a Groth16 proof that says "I am someone in this war's census, I haven't voted before, and my weight is what the attestor certified" — without revealing which someone. The proof is verified on-chain, inside the Solana program, using the bn254 syscalls. A nullifier makes double-voting cryptographically impossible. ZK is the only technology I know of that keeps the ledger's transparency and the ballot's secrecy at the same time: proof of the fact, silence about the identity.
Proof that your voice landed. Every verified vote lands in the war account, and every open browser sees the battlefront bar move in under half a second via websocket subscriptions. No indexer, no backend cache, no database — the chain is the live feed. You watch your own conviction become a pixel of territory, in real time.
And when the war closes, veterans claim a medal with a second zero-knowledge proof whose nullifier is derived under a different domain — so the medal you show off is mathematically impossible to link to how you voted. You can claim it to a fresh wallet while your ballot stays secret forever. Scars, without receipts.
Three wars are open right now. The scoreboard is eternal. Come vote.
Demo
Live war room: https://holly-wars-solana-zk-edition-app.vercel.app (devnet)
What to look at:
The scoreboard is reading Solana directly. There's a badge in the corner that says
LIVE · reading devnet— it's not decoration, it flips toDEMO DATAif the RPC is unreachable, so you always know whether you're looking at consensus or at a sample. Open the site, then open war #1 on the explorer. The tallies match because the page decodes the on-chainWaraccount and subscribes to it. There is no database anywhere in this project — the census itself is rebuilt from chain state.The whole voting flow runs in your tab. Enlist with real GitHub OAuth, let the Proof of Passion scanner read your repos, and then watch your browser download a 12,131-constraint circuit and forge a Groth16 proof locally. Your secrets never leave
localStorage. A wallet is optional — the relayer pays the fee, so you only need one if you want a medal later.A real proof, verified on-chain: the vote transaction. That single transaction verifies a zk-SNARK and updates the tally.
The anonymous medal claim, a second proof under a different nullifier domain — the medal lands on a brand-new wallet that has no on-chain relationship to the vote.
Repo: https://github.com/manuelpenazuniga/HollyWars-SolanaZK-edition
Anchor program (Rust) + circom circuits + a Next.js app that contains everything else: the war room, the in-browser prover, and the attestor and relayer running as API routes on the same Vercel deployment. Program ID FHj8ba…J1WG on devnet.
Intel From the Trenches
One declassified fact per battlefield, so you know what you're voting about:
- Tabs vs Spaces. Stack Overflow's 2017 survey analysis found that developers who indent with spaces earned ~8.6% more than tab users — the single most inflammatory scatterplot in developer history. Fifty years of war, and it turns out one side was literally getting paid more the whole time. (Correlation, causation, pitchforks — the comments are that way.)
-
Vim vs Emacs. In 2017 Stack Overflow published a blog post celebrating the one-millionth developer it helped exit Vim; the question's view counter has kept climbing into the millions since. Meanwhile the Church of Emacs holds that using vi is not a sin but a penance — though note that
vi vi viis, in Roman numerals, the editor of the beast. - Dark vs Light. Dark mode isn't a trend, it's a restoration: the first terminals were dark by physics (glowing phosphor on black CRT glass), and "light mode" is a Xerox-era simulation of paper that we all forgot was a simulation. Also, Google's own measurements showed dark UIs cutting display power by up to ~60% on OLED at max brightness — so this war has casualties measured in battery percent.
Pick your side. Now let me show you the machine that counts you.
How I Built It
The real problem: passion without doxxing
Any "vote with your wallet" design fails twice for this use case. It fails on sybils — wallets are free, and holy wars are exactly the kind of thing people would bot. And it fails on privacy — your wallet is your on-chain identity, and a vote you can be judged for is a vote you won't cast honestly. (Be honest: would you publicly stake your GitHub profile on light mode?)
Notice that these two failures pull in opposite directions. Fixing sybils means tying the vote to a real, verified identity. Fixing privacy means severing the vote from any identity at all. Most systems pick one and shrug at the other. The entire architecture of this project exists to refuse that trade:
GitHub OAuth ──► attestor ──► on-chain census (Merkle tree of commitments)
│ signs: commitment, weights, leaf index (Ed25519)
▼
Browser: secrets in localStorage ──► Groth16 proof ──► relayer ──► program
"I'm IN the census, my nullifier is fresh, verifies proof
my weight is what was attested — guess who" via bn254 syscalls
The attestor knows who you are but never sees your vote. The chain sees your vote but can never know who you are. The relayer pays the fee so your wallet never appears. The only thing that crosses between those worlds is a Poseidon commitment — identity flows down the left side, anonymity flows down the right, and they only meet inside a proof.
(Deployment footnote that surprised me: the attestor and relayer started the weekend as two standalone Express services, and ended it as API routes inside the same Next.js app — same origin, no CORS, one vercel deploy. Serverless means they can't keep the census in memory, so they rebuild it from on-chain accounts on every cold start. Which sounds like a limitation until you notice what it means: the blockchain is the only database this project has. The trust domains stayed separate; the processes didn't need to be.)
The census: GitHub as a passport
Why does the census matter so much? Because it's the difference between counting people and counting noise. Every real-world election starts with a voter roll; every internet poll skips it, and that's the original sin of online opinion.
When you enlist, the attestor scans your public repos (that's the Proof of Passion — counting meaningfully indented lines to weigh tabs-vs-spaces conviction, with a floor so drive-by accounts get weight 1), then signs an 80-byte message binding your commitment to the war and a census leaf index. The program verifies that signature with the Ed25519 native program and instruction introspection — and this is where the first audit finding lived: if you don't pin the instruction indices inside the Ed25519 data, an attacker can smuggle a signature that points at other instruction slots. Every offset gets pinned to 0xFFFF:
// all three index fields must be u16::MAX = "this instruction",
// or the signature can reference data outside this Ed25519 ix
require!(offsets.signature_instruction_index == u16::MAX
&& offsets.public_key_instruction_index == u16::MAX
&& offsets.message_instruction_index == u16::MAX,
ErrorCode::AttestationInvalid);
Your commitment Poseidon(Poseidon(seed, trapdoor), weight_a, weight_b) becomes a leaf in a depth-20 Merkle tree — 2²⁰ = 1,048,576 census seats per war, which should cover everyone who has ever @'d a coworker about indentation. The secrets are generated in your browser and never leave it:
// app/lib/identity.ts — the ballot secret lives in localStorage,
// the least glamorous vault in computing, and the only one the
// attestor can't subpoena because it never had it.
function randomFieldElement(): bigint {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
let x = 0n;
for (const b of bytes) x = (x << 8n) | BigInt(b);
return x % BN254_R; // circuit inputs must fit the bn254 scalar field
}
From the chain's point of view, you are now provably someone — and provably nothing more specific than that.
The vote: a polling place inside a browser tab
Here's the part that still feels like a magic trick to me. The whole point of a blockchain is that everything is checked in public — and the whole point of a secret ballot is that the crucial thing isn't visible. A zk-SNARK is how you check what you cannot see. (The idea is older than the web: Goldwasser, Micali and Rackoff published it in 1985, after the paper had been rejected three times. Two of the three later won the Turing Award. Be kind to weird papers.)
The circom circuit (12,131 constraints) proves four things at once: your leaf is in the census root, your nullifier is honestly derived, your side is binary, and your claimed weight matches the side you picked. The validators verify all four without learning your leaf, your seed, or your name.
And all of it — witness building, proving, self-verification — happens in the browser. The voting booth narrates its own progress, and honestly this array is my favorite piece of UX copy I've ever committed:
const FORGE_STAGES = [
"loading census tree — depth 20, room for 1,048,576 devs",
"building merkle path to your secret leaf",
"forging groth16 proof — 12,131 constraints",
"proof ready — 256 bytes of pure alibi",
"relaying anonymously — your wallet stays home",
"vote landed on-chain — nullifier burned",
] as const;
Two freak numbers hiding in there. First: the proof really is ~256 bytes, no matter what — Groth16 proofs (yes, it's just a guy: Jens Groth, 2016) are constant-size, so proving my 12,131 constraints weighs exactly as much as proving a billion. Your entire anonymous, weighted, double-vote-proof ballot is smaller than this paragraph. Second: the circuit is only that small because of Poseidon, a hash designed to be cheap inside circuits — a single SHA-256 would cost ~25,000+ constraints, more than my whole 21-hash Merkle path, nullifier and commitment combined. Choosing the wrong hash function wouldn't have made this slower; it would have made it not fit.
One design decision I'm genuinely proud of: the browser trusts nobody, including my own attestor. It doesn't ask the attestor for a Merkle path — a malicious attestor could hand you a path into a different tree. Instead it downloads the raw leaves, rebuilds the entire depth-20 tree locally (a verbatim port of the attestor's algorithm, because the roots must byte-match), and refuses to prove until its own root equals the one on-chain and its own leaf is actually in the snapshot:
// census-tree.ts — don't prove against a tree you didn't build.
// A stale-but-consistent RPC view (both fetches predating our enroll)
// would match roots yet omit our leaf → the circuit's root assert
// would eat the proof. Only accept a snapshot that contains us.
const leafPresent =
leaves.length > leafIndex && feToHex(leaves[leafIndex]) === expected;
if (leafPresent) {
const proof = await buildProof(leaves, leafIndex);
if (feToHex(proof.root) === onChainRootHex) return { proof, rootHex };
}
// ...otherwise poll: the attestor posts the root within ~10s of each enroll
On-chain, the program does the cheap checks first (war open, root posted, public inputs bound to this war), then verifies the proof with groth16-solana over Solana's alt_bn128 syscalls, and only then touches the tally. The nullifier PDA is created in the same transaction — the second vote with the same secret fails at account creation, not at some off-chain gate.
Getting a proof from snarkjs to that syscall is a gauntlet of serialization gotchas, and they fail silently: snarkjs happily verifies proofs the syscall rejects. The two that cost me hours live in one small file now, with a comment that reads like a warning label because it is one:
// serialize.ts — snarkjs proof -> groth16-solana bytes. DO NOT freehand-edit:
// the on-chain verify fails SILENTLY if a byte is wrong.
const a = be(proof.pi_a[0]) + negY(proof.pi_a[1]); // proof A is NEGATED (y -> q - y)
const b =
be(proof.pi_b[0][1]) + be(proof.pi_b[0][0]) + // every G2 point: Fp2 coords
be(proof.pi_b[1][1]) + be(proof.pi_b[1][0]); // swapped to imaginary-first
Negate only A (negate the verifying key too and everything breaks symmetrically), and swap (c0, c1) to (c1, c0) in both the proof and the verifying key. That file is pure — no snarkjs, no browser APIs — specifically so it could be golden-tested in node against a proof already proven on-chain. When the browser port produced the same bytes as the recorded fixture, I finally exhaled.
The bug that ate my Saturday morning
The vote instruction kept dying with an out-of-memory at 2,147 compute units — before my handler ran a single line. I did everything wrong first: swapped in a custom heap allocator, played with feature flags, convinced myself Groth16 just didn't fit on Solana.
The actual cause was one line of Anchor macro syntax:
#[instruction(war_id, nullifier_hash, battle_cry)] // 💀
#[instruction(war_id, nullifier_hash)] // ✅
Anchor deserializes #[instruction(...)] arguments sequentially from the front of the instruction data. By listing battle_cry but skipping the four proof arguments between it and nullifier_hash, Anchor tried to read a String length prefix out of the middle of my proof bytes — and cheerfully allocated a ~1 MB vector on a 32 KB heap. The lesson that survives this weekend: when a Solana program OOMs, measure what's allocating before believing your theory. The proof itself fits fine.
The trusted setup that wasn't (and the zkey I lost)
An adversarial review pass caught the scariest one: my Groth16 setup was degenerate — gamma == delta == the G2 generator, the snarkjs default before phase-2 contributions. With that key, anyone can forge "valid" proofs for arbitrary public inputs without ever knowing a witness. No hack needed; the math just lets them. I'd even noticed the equality earlier and dismissed it as a spike artifact. The fix is a real ceremony (contribution + public beacon), and the takeaway is blunt: a ZK system with a lazy setup is theater.
The literature calls the setup randomness "toxic waste," and serious projects treat it like the real thing: Zcash's 2016 ceremony ran on air-gapped machines with hardware physically destroyed afterward, and one participant famously performed his part from a car driving across Canada, to frustrate anyone who might be pointing an antenna at him. My ceremony was a phase-2 contribution and a public beacon in a shell script — no road trip — but the invariant is the same one those people drove hundreds of kilometers for: nobody knows the discrete log of delta.
Then, mid-weekend, I lost the proving key. Regenerated the whole setup, produced a new verifying key, upgraded it on-chain, and added a vkey-diff gate to the repo so the artifacts served to browsers can never silently drift from the key the program checks against. The anonymity layer is only as honest as the ceremony behind it — if you take one thing from this post, take that.
The most embarrassing bug wasn't cryptographic
Late in the weekend I ran a cold-eyes audit of the deployed app, as a user, and found this in the enlistment wizard:
onClick={() => setWalletConnected(true)} // "Connect wallet"
That's it. That was the wallet connection. It flipped a boolean and displayed a hardcoded address. Meanwhile, three layers down, the actual cryptography was real and audited. I had built a machine that verifies zk-SNARKs on-chain and bolted a cardboard door on the front.
It's a distilled version of the demo-theater trap: under deadline, the UI mocks you added "for now" quietly become the product. The fix was twofold. The button now goes through @solana/wallet-adapter for real (Phantom/Solflare via Wallet Standard) — and, because the relayer pays and anonymity is the point, it's honestly skippable: "Skip — I'll add a wallet for the medal later." A wallet you don't need is a wallet the UI shouldn't fake.
And second, the badge. Every screen now carries a data-origin indicator that cannot be dismissed: LIVE · reading devnet when the page is decoding real accounts, DEMO DATA · devnet unreachable when it fell back to samples. A project whose entire thesis is "don't trust, verify" doesn't get to blur the line between consensus and mock data — not even in its own marketing.
The 400ms scoreboard: watching your vote count
There's a quiet crisis of agency on the modern web: you say something into the void and get back… a like counter you suspect is inflated, a poll you suspect is botted, an algorithm that may or may not have shown your voice to anyone. You never really know if you registered.
This is the part of the project that answers that feeling. Every browser subscribes straight to the three war accounts:
connection.onAccountChange(warPda, (acc) => {
const war = decodeWarAccount(acc.data); // 90-line manual Borsh decoder
setWars(prev => update(prev, war)); // battlefront bar animates
}, "confirmed");
A vote confirms, the websocket fires, the bar moves — sub-second, for every viewer, with zero backend. On most chains a "live" dApp means an indexer, a queue and a cache doing block-time damage control. Here the voting booth and the stadium jumbotron are the same account. You cast a ballot and watch the territory shift because of you, knowing — cryptographically, not statistically — that it moved exactly once, by exactly the weight your passion earned. Your opinion, reflected back at you in 400 milliseconds: that's the feedback loop the web lost, rebuilt on consensus.
The medal: bragging rights without a paper trail
Voting anonymously is only half the game — fans want scars. Passion demands trophies, and trophies are usually where anonymity goes to die: the moment you claim your "I was there" badge, you've linked yourself to everything else. So the claim uses a second circuit that proves the same census membership but derives its nullifier under a different domain separator:
vote: Poseidon(seed, war_id, "VOTE") // spent when you vote
medal: Poseidon(seed, war_id, "MEDL") // spent when you claim
Without the seed, the two nullifiers are unlinkable — so you claim your veteran medal to any wallet you like (I claim mine to a fresh one in the demo) and nobody, including me, can connect it to your ballot. It's the same trick privacy mixers use, pointed at something sillier and therefore better. (Freak detail: the domain separators are literally ASCII smuggled into field elements — 1448039493 is 0x56_4F_54_45, which spells "VOTE", and the medal domain spells "MEDL". Cryptographic unlinkability, written out loud.)
Honest limits
Weekend software, honestly labeled: the attestor is a trusted gatekeeper (it can censor enrollment, though it can't forge votes or see ballots); the relayer sees your IP — and since it's a Vercel function now, so does Vercel — so route through Tor if your tabs-vs-spaces stance is that sensitive; tiny censuses have timing-correlation risks (enroll-then-instantly-vote narrows the anonymity set); and medals are census-membership proofs recorded as program accounts — the compressed-NFT mint via Bubblegum is the next upgrade. Roadmap: a third circuit proving "I voted for the winning side" without revealing which vote was mine, permissionless war creation, and quadratic weights.
(Transparency: I pair-programmed this with Claude — architecture, circuits, and every bug story above included. The degenerate-setup catch came out of an adversarial AI review pass; the fake wallet button was caught by an audit I pointed at my own deployment. Human-AI joint operation, which felt appropriate for a challenge about passion projects.)
Prize Category: Best Use of Solana
This project doesn't use Solana so much as it's only possible on Solana:
-
On-chain Groth16 verification via the
alt_bn128syscalls — a 12k-constraint SNARK verified inside one transaction's compute budget (~119k CU for the whole vote instruction). Very few L1s can do this natively at all, let alone for fractions of a cent. (The syscall's name is a fossil:alt_bn128is what Ethereum called this curve in its precompiles — Solana kept the name, so the fastest chain verifies its proofs on a curve named by its rival. Holy wars all the way down.) - PDAs as cryptographic state machines — census entries, leaf-uniqueness markers, vote nullifiers and medal nullifiers are all just seeded accounts; "double vote" becomes "account already exists," enforced by the runtime itself.
- Instruction introspection to verify Ed25519 attestations natively, no oracle.
-
Sub-second finality as UX —
onAccountChangeturns consensus into a live sports feed with no infrastructure, and turns "did my vote even count?" into something you can watch. (Solana can do this because it keeps time by hashing: Proof of History is a sequential SHA-256 chain — the scoreboard's heartbeat is literally a cryptographic clock.) - The chain as the only database — the census Merkle tree is rebuilt from program accounts on every serverless cold start; there is no Postgres, no Redis, no cache to go stale or get hacked.
- Anchor program, three real devnet wars, gasless votes through a relayer, in-browser proving, and every claim in this post is a clickable devnet transaction.
Fifty years of war. One census, two proofs, three battlefields, four hundred milliseconds.
The war is open until Monday. The scoreboard is forever. Tabs or spaces — prove it.










Top comments (0)