Midnight Network Leaderboard DApp
A privacy-preserving leaderboard where players submit scores on-chain with three display modes and prove they own an entry using verifyOwnership — a ZK circuit that checks ownerCommitment(secretKey) matches the stored ownerHash without revealing the secret.
Runnable template: Copy templates/leaderboard-dapp/ for a complete Next.js project (contract + UI). Run npm install && npm run compact && npm run sync:assets && npm run dev after installing the 1AM wallet.
What this skill produces:
contract/— Compact leaderboard + TypeScript witnesses + compile scriptsapp/leaderboard/— Next.js client UI (click game, privacy mode picker, leaderboard table)lib/midnight.ts— wallet session + patched indexer provider (copy fromreferences/midnight-session.mdortemplates/leaderboard-dapp/lib/midnight.ts)lib/leaderboard.ts— deploy,submitScore,verifyOwnership, ledger decodelib/display-name.ts— decode anonymous hash bytes to generated names ("Crimson Tiger")public/zk/leaderboard/— ZK proving assets synced from contract build
Shared references (canonical provider + troubleshooting — do not duplicate in prompts):
references/midnight-session.md—createConnectedSession, indexer patch, deploy/call helpersreferences/gotchas.md— preprod deploy hangs, GraphQLoffset: null, ZK asset pathsreferences/versions.json— pinned@midnight-ntwrk/*versions
Primary references:
example-locker-dapp//templates/locker-dapp/— Next.js + 1AM pattern, low-level deploy/callexample-payment-dapp/— provider wiring, indexer pollingcompact/—disclose(), witnesses,persistentHash,Map,Counter,assertsecurity/— what is public on-chain vs private in witness dataindexer/— read contract state without wallet connection
Key architecture notes:
scoresis a publicMap<Uint<64>, ScoreEntry>— readable from the indexer by anyonelocalSecretKeywitness returns a 32-byte secret from private state; never disclosed on-chainownerCommitment(sk)usespersistentHashwith domain separator"leaderboard:owner:"useCustomNameboolean selects witness-fed name vs hash-based anonymous display nameverifyOwnershipis a proof-only circuit — no ledger writes; use for "prove this is my score"- Persist secret key in
localStorageso ownership proofs work after page refresh - Use
createUnprovenDeployTx+submitTxAsync— notdeployContract()(hangs on preprod) - Wrap
indexerPublicDataProviderwith patchedqueryContractState(GraphQLoffset: nullbug) - Leaderboard table can load from indexer without wallet — only submit/verify need connection
Workflow
When helping the user, follow this sequence:
- Contract —
leaderboard.compactcompile + witnesses (localSecretKey,getCustomName) - Providers —
createConnectedSession(fromreferences/midnight-session.md) - Deploy — low-level deploy, persist private state + contract address
- Read state — indexer GraphQL →
ContractState.deserialize→ledger()→ decode entries - Submit score —
submitScore(score, useCustomName)with optionalsetCustomNamebeforehand - Verify ownership —
verifyOwnership(entryId)for prize claims or "yours" badges - UI — click game, privacy mode selector, leaderboard table with prove button
1) Project Structure
leaderboard-dapp/
├── package.json
├── next.config.mjs
├── lib/
│ ├── isomorphic-ws-fix.mjs
│ ├── midnight.ts # session, patched provider, hex helpers
│ ├── leaderboard.ts # deploy, submitScore, verifyOwnership, decode
│ └── display-name.ts # anonymous name generator
├── app/
│ ├── layout.tsx
│ └── leaderboard/
│ └── LeaderboardClient.tsx # game + wallet + leaderboard UI
├── contract/
│ ├── package.json
│ └── src/
│ ├── leaderboard.compact
│ ├── witnesses.ts
│ ├── index.ts
│ └── managed/leaderboard/ # compiler output (gitignored)
├── scripts/
│ └── sync-zk-assets.mjs # → public/zk/leaderboard/
└── public/zk/leaderboard/ # keys + zkir (gitignored until sync)
2) Compact Contract
contract/src/leaderboard.compact:
struct ScoreEntry {
score: Uint<64>,
displayName: Bytes<32>,
ownerHash: Bytes<32>
}
export ledger scores: Map<Uint<64>, ScoreEntry>;
export ledger nextId: Counter;
witness localSecretKey(): Bytes<32>;
witness getCustomName(): Bytes<32>;
export circuit ownerCommitment(sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([pad(32, "leaderboard:owner:"), sk]);
}
export circuit submitScore(score: Uint<64>, useCustomName: Boolean): [] { /* ... */ }
export circuit verifyOwnership(targetEntryId: Uint<64>): [] { /* ... */ }
Privacy design:
- Anonymous mode (
useCustomName = false):displayName = persistentHash(sk)— UI decodes to generated name - Public / Custom mode (
useCustomName = true):displayName = getCustomName()witness — app feeds address or user name - Ownership:
ownerHash = ownerCommitment(sk)stored on every entry;verifyOwnershipre-derives and compares
Compile:
cd contract && npm run compact && cd ..
npm run sync:assets
3) Witnesses
contract/src/witnesses.ts:
export type LeaderboardPrivateState = { secretKey: Uint8Array };
export const witnesses = {
localSecretKey: (ctx) => [ctx.privateState, ctx.privateState.secretKey] as const,
getCustomName: (ctx) => [ctx.privateState, customName] as const,
};
export const setCustomName = (name: string) => { /* encode to Bytes<32> */ };
Call setCustomName(name) in TypeScript before submitScore when using public or custom display modes.
4) TypeScript Integration
lib/leaderboard.ts mirrors the locker-dapp pattern:
| Function | Purpose |
|---|---|
getOrCreateSecretKey() |
Persist 32-byte secret in localStorage |
deployLeaderboard(session) |
createUnprovenDeployTx + submitTxAsync |
submitScore(session, addr, score, customName?) |
submitCallTxAsync → submitScore circuit |
verifyOwnership(session, addr, entryId) |
submitCallTxAsync → verifyOwnership circuit |
fetchLeaderboardState(queryUrl, addr) |
Indexer poll + decodeLeaderboardState |
ZK asset path: /zk/leaderboard (synced to public/zk/leaderboard/).
5) Browser UI
LeaderboardClient.tsx flow:
- Connect —
detectWallet()→wallet.connect('preprod')→createConnectedSession(api, ZK_PATH) - Deploy / Join — deploy new contract or paste existing 64-char hex address
- Play — 10-second click challenge
- Submit — pick Anonymous / Public / Custom, then
submitScore - Leaderboard — auto-refresh from indexer every 15s (no wallet required to read)
- Prove —
verifyOwnership(entryId)marks entry as yours in UI
Indexer-only reads mean spectators can watch the leaderboard without connecting a wallet.
6) Prerequisites
| Component | Version |
|---|---|
| Compact compiler | 0.31.0 |
| Compact runtime | 0.16.0 |
| Ledger | 8.0.3 |
| midnight-js | 4.0.4 |
| proof server | 8.0.3 |
docker run -d -p 6300:6300 midnightntwrk/proof-server:8.0.3 -- \
midnight-proof-server --network preprod
Wallet: 1AM extension on Preprod, proof server http://localhost:6300, tNIGHT + tDUST from faucet.
7) Compatibility Matrix
Pin versions from references/versions.json. Cross-check docs.midnight.network support matrix before upgrading.
8) Production Notes
- Deploy frontend to Vercel/Netlify; set
public/zk/leaderboardassets in build step (npm run sync:assets) - Store default contract address in env or
localStoragefor returning players verifyOwnershipdoes not mutate ledger — safe for repeated "prove mine" UX- For mainnet, update network ID, indexer URLs, and proof server config via wallet
getConfiguration()
Quick Commands
cd templates/leaderboard-dapp
npm install
npm run compact
npm run sync:assets
npm run dev
Open http://localhost:3000 → Connect 1AM → Deploy New → play → submit → refresh leaderboard.