self-olympics: A Country Leaderboard That Knows Who You Are, Without Knowing You
Self-Olympics uses passport-backed zero-knowledge proofs, a Farcaster Frame, and a PostgreSQL nullifier ledger to make one-human, one-vote ranking feel practical.
- Self-Olympics treats sybil resistance as a data minimization problem, not a surveillance problem.
- Its most important state is a nullifier, because that is what makes one-human, one-vote enforceable without saving passport details.
- Farcaster is the delivery surface, while PostgreSQL is the operational truth and Celo is the eventual settlement layer.
- The repo’s hybrid stack is a feature, not a compromise, because it ships the verification loop before the chain logic is finished.
The hard problem here is not identity. It is repeat identity. Self-Olympics tries to answer a blunt question: how do you build a country leaderboard that counts humans once, proves nationality privately, and still feels native inside a social app?
A prototype with production instincts
The repository reads like a working prototype, but not a throwaway one. A Turborepo splits the Next.js app, the verification API, and the contract workspace, while Farcaster metadata and webhook code show the product is meant to live inside Warpcast rather than beside it.
The thin line between proof and profile
The core idea is elegant. The app does not need your passport number or your full identity. It needs a zero-knowledge proof that a Self-compatible passport check succeeded, plus a nullifier that says this same human has already registered.
That is why the database is not just storage. It is the gatekeeper for uniqueness. The moment the nullifier has an entry, the app stops treating a fresh request as a fresh person.
How the backend enforces one human, one vote
The heart of the app sits in apps/web/src/app/api/register/route.ts. The route verifies the proof, checks the nullifier, and only then writes a registration row tied to a country code.
const proof = await verifier.verify(request.body.proof);
const duplicate = await db.query(
'select country_code from registrations where nullifier = $1',
[proof.nullifier]
);
if (duplicate.rowCount > 0) {
return Response.json({ error: 'Already registered' }, { status: 409 });
}
await db.query(
'insert into registrations (nullifier, country_code) values ($1, $2)',
[proof.nullifier, proof.nationality]
);
The route also requests nationality, which is enough to increment a country count without turning the system into a dossier. In other words, the app keeps the attribute it needs and discards the rest.
Why Farcaster matters more than the chain
The frame metadata, QR verification flow, and notification webhook point to a distribution strategy, not just a feature list. A user can encounter the app inside a feed, verify on mobile, and return with the result without leaving the social loop.
That makes Farcaster the real front door. The blockchain stack is there, but the user experience is built around the place where people already are, which is where adoption actually starts.
A pragmatic comparison
| Approach | Identity signal | Trade-off |
|---|---|---|
| Self-Olympics now | ZK passport proof plus nullifier | Strong privacy and sybil resistance, but it still depends on backend state |
| Fully on-chain registry | Wallet or contract record | Easy to audit, harder to keep cheap and private |
| Simple signup form | Email or wallet only | Fast to launch, easy to game |
The current architecture is a deliberate middle path. The contract folder is configured for Celo, but the visible source of truth is still PostgreSQL, which is sensible when the product is changing fast and the most important thing is getting the verification loop right.
That is the real maturity signal here. The repository knows which parts must be trust-minimized today and which parts can stay operationally simple until the rules are stable enough to move on-chain.