For the complete documentation index, see llms.txt
Leaderboard DApp
The leaderboard smart contract demonstrates privacy-preserving score tracking on Midnight using the Compact language. This example shows how to create a DApp that lets players submit scores with configurable privacy modes while protecting player identity through zero-knowledge (ZK) proofs.
The leaderboard example introduces key Midnight concepts:
- Writing smart contracts with structured on-chain data using
Mapand custom structs in Compact - Using ZK proofs to verify entry ownership without revealing the owner's secret key
- Implementing multiple privacy modes through conditional witness invocation
- Building a browser-based frontend that connects to the Lace wallet
By the end of this guide, you will understand how the leaderboard contract enforces privacy rules and how to build and interact with the full DApp in your browser.
The leaderboard scenario
Imagine an arcade leaderboard where players want to record their high scores. The rules are straightforward:
- Anyone can submit a score at any time.
- Each entry stores a score, a display name, and an ownership commitment.
- Players choose how their display name appears: a custom name, their public address, or an anonymous generated name like "Crimson Tiger."
- Only the original submitter can prove ownership of their entry using a ZK proof.
The challenge is enforcing ownership rules while giving players full privacy control. Traditional systems require storing the player's identity on a server. Midnight offers a better approach: players prove ownership locally through ZK proofs without transmitting private data across the network.
DApp architecture
The leaderboard example uses a monorepo structure with three workspace packages and a Docker configuration for the proof server.
midnight-leaderboard/
├── contract/ # Compact smart contract and TypeScript bindings
│ ├── leaderboard.compact # The smart contract
│ └── src/
│ ├── index.ts # Contract exports and witness wiring
│ └── witnesses.ts # Witness provider
├── api/ # Shared business logic (platform-agnostic)
│ └── src/
│ ├── index.ts # LeaderboardAPI class
│ ├── common-types.ts # Shared types and provider interfaces
│ └── utils/index.ts # Display name decoder
├── leaderboard-ui/ # React and Vite browser DApp
│ └── src/
│ ├── App.tsx # Game UI, leaderboard, and wallet connection
│ ├── main.tsx # Entry point with Buffer polyfill
│ ├── contexts/ # Lace wallet provider bridge
│ └── hooks/ # Indexer read hook
└── proof-server/ # Docker config for the proof server
└── Dockerfile
The leaderboard contract
The leaderboard contract is written in Compact. It demonstrates how to build a privacy-preserving application where players can submit scores with configurable display names and prove ownership of their entries without revealing their identity.
Here is the complete contract:
pragma language_version 0.23;
import CompactStandardLibrary;
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
): [] {
const sk = localSecretKey();
const ownerHash = ownerCommitment(sk);
nextId.increment(1);
const entryId = disclose(nextId.read() as Uint<64>);
if (disclose(useCustomName)) {
const customName = getCustomName();
scores.insert(entryId, ScoreEntry {
score: disclose(score),
displayName: disclose(customName),
ownerHash: disclose(ownerHash)
});
} else {
scores.insert(entryId, ScoreEntry {
score: disclose(score),
displayName: disclose(persistentHash<Bytes<32>>(sk)),
ownerHash: disclose(ownerHash)
});
}
}
export circuit verifyOwnership(targetEntryId: Uint<64>): [] {
assert(scores.member(disclose(targetEntryId)), "entry not found");
const entry = scores.lookup(disclose(targetEntryId));
const callerHash = ownerCommitment(localSecretKey());
assert(callerHash == entry.ownerHash, "not the owner");
}
The contract consists of three main components.
Ledger state
Two public fields track the leaderboard state on-chain:
scores: AMap<Uint<64>, ScoreEntry>that stores all submitted entries, keyed by an auto-incrementing ID. EachScoreEntryholds a score, a display name, and an ownership commitment.nextId: ACounterthat auto-increments to assign a unique ID to each new entry.
Circuits
Three exported circuits define the contract's operations:
ownerCommitment(sk): A helper circuit that derives an on-chain identity from your secret key usingpersistentHash. It produces a deterministic commitment that you can store and verify later without revealing the secret.submitScore(score, useCustomName): Creates a new leaderboard entry. It retrieves your secret key via thelocalSecretKeywitness, computes the owner commitment, and inserts a newScoreEntryintoscores. TheuseCustomNameflag controls whether the display name comes from thegetCustomNamewitness or derives directly from the secret key hash.verifyOwnership(targetEntryId): Lets you prove you own a specific entry. It looks up the entry, recomputes the owner commitment from your secret key, and asserts that it matches the storedownerHash. Nothing is written to the ledger.
Witnesses
The contract defines two witness functions:
localSecretKey(): Returns your secret key from private state. The key never appears on-chain or in any generated proof.getCustomName(): Returns a custom display name that the TypeScript host provides at runtime. The TypeScript layer decides what value to supply based on your selected privacy mode.
Prerequisites
Before working with the leaderboard example, ensure that you have:
- Node.js version 22 or higher
- Docker Desktop installed and running
- Compact toolchain installed
- Lace wallet browser extension installed
For more information, refer to install the toolchain.
Set up the example
Clone the repository
Get the leaderboard example from GitHub:
git clone https://github.com/midnightntwrk/midnight-leaderboard.git
cd midnight-leaderboard
Install dependencies
Install all required Node.js packages:
npm install
This command installs packages for the contract, API, and UI components.
Start the proof server
The proof server generates ZK proofs for transactions locally to protect private data. It must be running before you can deploy or interact with contracts.
Start the proof server:
docker run -p 6300:6300 midnightntwrk/proof-server:8.0.3 -- midnight-proof-server -v
The proof server must stay active while using the DApp.
Compile the contract
Open a new terminal window at the project root and navigate to the contract directory:
cd contract
npm run compact
This command compiles the contract and generates TypeScript bindings, circuit keys, and ZKIR files in contract/managed/leaderboard/.
You should see the following output:
Compiling 2 circuits:
circuit "submitScore" (k=13, rows=4720)
circuit "verifyOwnership" (k=13, rows=2352)
Build the contract and API packages:
cd ..
npm run build
Set up the wallet
The browser DApp connects to the Lace wallet for on-chain transactions.
- Install the Lace browser extension and create a new wallet.
- Set the network to Preprod.
- Set the proof server to
http://localhost:6300. - Fund your wallet with tNIGHT from the Preprod faucet.
- Go to Tokens, click Generate tDUST, and confirm the transaction.
You need tDUST to pay transaction fees on Preprod. The wallet generates tDUST from your tNIGHT balance.
Run the browser DApp
Navigate to the leaderboard-ui directory, install dependencies, and start the development server:
cd leaderboard-ui
npm install
npm run dev
Open your browser at http://localhost:3000. The leaderboard UI loads and connects to the Preprod indexer.
Interact with the leaderboard
Connect your wallet
Click Connect Wallet in the top-right corner of the UI. Lace prompts you to authorize the connection. After you approve, your wallet address and tDUST balance appear in the header.
Deploy a new contract
Click Deploy Contract. The UI calls LeaderboardAPI.deploy(), which submits a deployment transaction through Lace. Wait for the transaction to confirm on Preprod.
Contract deployment typically takes 20 to 30 seconds on Preprod as the network processes your transaction and ZK proofs.
After confirmation, the contract address appears in the UI.
Submit a score
The game area displays a score counter. Play the game or enter a score manually, then choose a display name mode before submitting:
- Custom name: Enter a name up to 32 characters. The
getCustomNamewitness supplies this value to the circuit. - Public address: The
getCustomNamewitness passes your wallet address as the display name. - Anonymous: The contract does not invoke the
getCustomNamewitness. Instead, the circuit storespersistentHash(secretKey)as the display name. ThelocalSecretKeywitness is still called to compute the owner commitment. The UI renders anonymous entries as generated names like "Crimson Tiger."
Click Submit Score. The UI:
- Retrieves your secret key from private state via the
localSecretKeywitness. - Computes your owner commitment using
ownerCommitment(secretKey). - Generates a ZK proof locally via the proof server.
- Submits the transaction through Lace.
- Waits for confirmation and refreshes the leaderboard.
Your secret key never leaves your machine. The proof server generates the ZK proof locally before anything is sent to the network.
View the leaderboard
The leaderboard table updates automatically after each confirmed transaction. It reads state directly from the Preprod indexer without requiring a wallet connection. Anonymous entries appear with generated names. Custom and public-address entries appear as submitted.
Verify entry ownership
Click Verify Ownership next to any of your entries. The UI calls verifyOwnership(entryId), which generates a ZK proof that your secret key matches the ownerHash stored in the entry. The circuit asserts the match on-chain without writing anything to the ledger.
A success message confirms ownership. If your wallet does not hold the correct secret key for that entry, the assertion fails and the proof server rejects the transaction locally before it reaches the network.
Ownership verification only works from the same browser and wallet instance that submitted the entry, because the secret key lives in the local private state store.
Read state without a wallet
You can view the leaderboard without connecting a wallet. The UI fetches the scores map directly from the indexer using the LeaderboardIndexerProvider. This is read-only access. Score submission and ownership verification require a wallet connection.
Next steps
Now that you understand the leaderboard example:
- Explore the GitHub repository: Leaderboard repository
- Build the contract yourself: Follow the complete Leaderboard tutorial to build every layer of the DApp from scratch.
- Explore the bulletin board example: The Bulletin board DApp is a simpler starting point that shows a single-state contract with a CLI interface.