For the complete documentation index, see llms.txt
Part 1: The smart contract
This part walks through
contract/leaderboard.compact,
the Compact contract behind the leaderboard, and the in-memory tests that check it.
State and witnesses
The contract stores a ScoreEntry struct in a Map keyed by an auto-incrementing counter, and
declares two witnesses that let the TypeScript host feed private data into a circuit.
pragma language_version 0.28;
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>;
scores is a public ledger field that anyone can read from the indexer. nextId counts entries
and gives each one its own key. The two witness declarations tell the compiler that the
TypeScript host supplies localSecretKey and getCustomName at run time:
localSecretKeyreturns the player's secret key, kept in their private state.getCustomNamereturns the display name for the current submission.
Neither value appears on-chain unless a circuit passes it through disclose().
ownerCommitment
This pure circuit derives an on-chain identity from a secret key. It hashes the key with a domain
separator using persistentHash, giving a commitment that can be stored on the ledger and checked
later without revealing the key.
export circuit ownerCommitment(sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([pad(32, "leaderboard:owner:"), sk]);
}
The domain separator "leaderboard:owner:" keeps this hash from colliding with hashes of the same
key made for other purposes. Because ownerCommitment is exported and uses no ledger state or
witness, the compiler also emits it as a pure function in TypeScript, so a test or a UI can compute
the expected commitment off-chain.
submitScore
The main circuit creates a new entry with the caller's score, display name, and owner commitment.
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)
});
}
}
localSecretKey()calls the witness to get the player's secret. It is a private input to the proof; only hashes of it reach the ledger.nextId.increment(1)thennextId.read()gives the new entry's id. The first entry is 1.disclose()marks a value as safe to make public. Every value a ledger write stores, and every branch condition derived from a circuit argument, has to be disclosed.- The
ifdecides the display name. WhenuseCustomNameis true, the circuit calls thegetCustomName()witness. When it is false, no witness is called and the display name ispersistentHash(sk): raw hash bytes that the UI renders as a generated name such as "Crimson Tiger".
Every call adds an entry, so the same player can appear many times, as on an arcade cabinet. All
of one player's entries carry the same ownerHash, so they can be linked to each other, though
not to the player's wallet.
verifyOwnership
This circuit lets a player prove they own an entry without revealing their secret key.
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 caller proves that ownerCommitment of their witness key matches the ownerHash stored in
the entry. It writes nothing to the ledger: a successful transaction is the proof. A DApp can use
it to claim a prize or unlock a badge. Anyone watching the chain sees that someone proved
ownership of that entry id, but not who.
Compile the contract
From examples/leaderboard:
npm run compile
Compiling 2 circuits:
The compiler writes the TypeScript bindings, the proving and verifying keys, and the ZKIR for
submitScore and verifyOwnership to contract/managed/leaderboard/. ownerCommitment is
pure, so it needs no keys.
Unlike the other examples, this directory is committed to the repository. The Vercel build in Part 4 has no Compact compiler, so it takes the keys from here. If you change the contract, compile it again and commit the result; CI fails if the committed output doesn't match a fresh compile.
Test the contract in memory
src/test/leaderboard.sim.test.ts
runs the circuits in memory, with no network and no proofs. Each test deploys a fresh leaderboard
with Alice's private state:
const ALICE_SK = bytes32();
const BOB_SK = bytes32();
const deploy = () =>
Sim.deploy({
contract: new Contract(witnesses),
ledger,
privateState: createLeaderboardPrivateState(ALICE_SK),
});
To act as Bob, a test swaps the private state the next call's witnesses see. This test checks that
verifyOwnership rejects a key that didn't make the entry:
it('rejects an ownership proof from another key', async () => {
const sim = await deploy();
await sim.call('submitScore', 99n, false);
sim.privateState = createLeaderboardPrivateState(BOB_SK);
await expectRejects(() => sim.call('verifyOwnership', 1n), 'not the owner');
});
The last test is the privacy check: after several submissions and a proof, neither player's secret key appears anywhere in the public ledger.
it('never puts a secret key in public state', async () => {
const sim = await deploy();
setCustomName('alice');
await sim.call('submitScore', 5n, true);
await sim.call('submitScore', 6n, false);
sim.privateState = createLeaderboardPrivateState(BOB_SK);
await sim.call('submitScore', 8n, false);
await sim.call('verifyOwnership', 3n);
assertNotInPublicState(sim.ledger(), { aliceSecretKey: ALICE_SK, bobSecretKey: BOB_SK });
Run them:
npm run test:sim
Next steps
In Part 2 you wire the contract to TypeScript: its witnesses, its exports, and an API class that the browser DApp and the network tests share.