Edda Sprint Winners: The Code Review
Over the course of the Edda Labs sprint, participants raced through tasks, stacked up points, and — most importantly — shipped real code on Midnight using our starter template. As promised, the top accumulators earned a full code review of what they built.
This post is that review. Three repositories, two winners:
| Place | Winner | Project(s) |
|---|---|---|
| 🥇 1st | tminus1sec | bahamas-2026-private-poll, midnight-allowlist-token |
| 🥉 3rd | Spy | nightforce-intelligence |
The bar we reviewed against
Everyone in the sprint started from the midnight-starter-template: a pnpm + Turbo monorepo with a counter contract (counter-contract), an interactive CLI (counter-cli), and a React frontend (frontend-vite-react).
Since the sprint ended, we've been building its successor — the modular-starter — and it's the reference we point to throughout this review. The headline changes worth knowing:
- One deployed contract, many Compact modules. Features live as independent Compact
modules (src/modules/<feature>/<Feature>.compact), imported with an explicitprefixand composed into a single entrypoint contract. The thin contract-level wrapper circuits are where validation and access control belong. compactis a first-class pipeline stage. Inturbo.json,build,test, andlintall depend on thecompacttask — nothing runs against stale compiler output.- A documented privacy envelope per circuit. Every circuit's header comment states which arguments become public inputs, verified against the emitted ZKIR — and deliberate simplifications (like an ungated
mint) are flagged inline with a named remediation. - Two-tier testing. Fast simulator unit tests in the contract package (with a multi-caller
.as("player")API), plus real-transaction integration tests against a dockerized standalone network. - Pinned everything. Compiler version inline in the compile script (
compact compile +0.31.0 …), exact SDK versions at the workspace root, pinned Docker image tags,.node-version+engines+packageManagerall consistent.
With that bar in mind, let's look at what our winners built.
🥇 First place: tminus1sec
tminus1sec took the top spot with two submissions — and they're a great pair, because each one shows a different kind of discipline: one is a full-stack dApp with real privacy engineering, the other is a small, surgically scoped contract library with excellent documentation.
Bahamas 2026 Private Poll
1st place · tminus1sec
bahamas-2026-private-poll
- Compact
- React 19
- Vite 6
- TanStack Router
- Tailwind 4
- pnpm + Turbo
A privacy-themed community straw poll for the May 2026 Bahamas general election. Voters pick a constituency and cast a ballot for a candidate; each vote is anonymous, and each secret key can only vote once. The public tallies update live, per candidate.
The contract: a real nullifier scheme, not a renamed counter
The heart of the project is a genuinely privacy-relevant construction. The voter proves control of a secret key (a private witness), and the contract records only a nullifier — a one-way hash of that key. Voting twice reproduces the same nullifier, which is already spent, so the second vote is rejected. The key never touches the ledger:
// the voter's secret identity key (never disclosed)
witness localSecretKey(): Bytes<32>;
export circuit vote(candidate: Uint<16>): [] {
const nf = persistentHash<Vector<2, Bytes<32>>>(
[pad(32, "bahamas-poll:nullifier:v1"), localSecretKey()]
);
assert(!spentNullifiers.member(disclose(nf)), "You have already voted in this poll");
spentNullifiers.insert(disclose(nf), true);
const id = disclose(candidate);
if (!votes.member(id)) {
votes.insert(id, 0);
}
const current = votes.lookup(id);
votes.insert(id, (current + 1) as Uint<64>);
}
Three things here deserve a call-out, because they're exactly what we hope to see in Midnight contracts:
- Correct
disclose()discipline. Only the nullifier and the candidate id — the two values that must reach the public ledger — are disclosed. The raw witness never is. - The right hash for the job.
persistentHash(nottransientHash) is the correct choice for a value persisted on-chain across transactions. - A domain-separated, versioned nullifier. The
"bahamas-poll:nullifier:v1"tag scopes the nullifier to this poll. Without it, the same secret key would produce an identical value in any other contract that hashes a key the same way — and remarkably, the in-source comment documents that this actually happened between this poll and the same author'smidnight-allowlist-tokenbefore both were fixed. Finding a real cross-contract nullifier collision during a sprint and fixing it with versioned domain tags is the single best story in this whole review.
Recommendation: let the ledger do the counting
The main design improvement we'd suggest is in the tally update. The lookup → insert sequence is a read-modify-write: the transaction reads the current tally and writes back current + 1, which couples it to the exact ledger state it was proven against. Under concurrent voting — two people voting for the same candidate against the same observed state — those transactions contend, and for a live poll that's the difference between a demo and production.
Compact has a purpose-built answer: Counter works as a Map value, and its increment is an atomic delta rather than a read-and-replace. We verified this compiles and executes correctly (independent per-key counters, accumulating across calls):
export ledger votes: Map<Uint<16>, Counter>;
export circuit vote(candidate: Uint<16>): [] {
// ...nullifier check as before...
const id = disclose(candidate);
if (!votes.member(id)) {
votes.insert(id, default<Counter>);
}
votes.lookup(id).increment(1);
}
Two smaller state-design refinements in the same spirit:
spentNullifiersshould be aSet<Bytes<32>>, not aMap<Bytes<32>, Boolean>whose value is alwaystrue. Samemember/insertAPI, cheaper and clearer intent (spentNullifiers.insert(disclose(nf));).- Validate the candidate id.
Uint<16>accepts 0–65535, but only ids 0–14 map to real candidates. A one-lineassert(candidate < 15, "unknown candidate")prevents junk tallies and unbounded ledger growth. In the modular-starter pattern, the thin contract-level wrapper circuit is exactly where this validation belongs.
And one lifecycle thought for a v2: the poll has no constructor, no owner, and no open/close window — it's permanently open by design. Fine for a straw poll, but an export sealed ledger owner plus a close circuit would make it a complete election primitive.
Tests, tooling, and the frontend
The contract test suite is the kind we like: a multi-identity simulator (sim.as("p1") / sim.as("p2") with per-user private state) driving five focused tests — including the double-vote rejection and an assertion that the rejected attempt left the tally untouched. Keys come from crypto.getRandomValues, both in tests and in the browser, where the secret key is generated once, persisted through the private-state provider, and never leaves the device.
The frontend is genuinely custom work: a ballot UI with constituency selection, per-candidate result bars with bigint-safe percentage math, and a four-stage transaction tracker (Prove → Sign → Submit → Confirm). The election data module cleanly maps on-chain ids to (constituency, party, candidate) and labels itself a demonstration poll.
Where the polish runs out is at the edges of the starter template:
- The CLI's tests still call the deleted
api.incrementand request the old"increment"circuit keys — they can't pass. The CLI also hardcodes candidate0, so the terminal path can't actually exercise the ballot. - CI lints and typechecks with
continue-on-error: trueon every step, and never compiles the contract or runs the simulator tests — the one suite that validates the nullifier logic isn't enforced anywhere. - "Counter" naming survives everywhere: the route is
/counter, the SDK iscounter-sdk, the root package is still@eddalabs/starter-template, and the CLI banner still says Midnight Counter Example. - Inherited template baggage (
CONTRIBUTING.mdpointing at the upstream repo, the wholeeducational-material/tree) shipped along in the fork.
What we loved
- Real privacy engineering: domain-separated, versioned nullifier with correct disclose() placement
- Exceptionally honest privacy documentation — disclaims what it doesn't do and names the fix
- Found and fixed an actual cross-contract nullifier collision during the sprint
- Multi-identity simulator tests including double-vote rejection
- Purpose-built ballot UI with staged transaction progress
- Clean secret handling: CSPRNG keys, nothing committed, LFS for ZK artifacts
Level-up opportunities
- Use Map<Uint<16>, Counter> + increment to avoid read-modify-write contention
- Use Set<Bytes<32>> for spent nullifiers; validate candidate id bounds
- Fix the broken leftover CLI tests and the hardcoded candidate 0
- Make CI actually fail: compile the contract and run the simulator tests
- Rename the counter-era scaffolding; drop inherited template docs
Midnight Allowlist Token
1st place · tminus1sec
midnight-allowlist-token
- Compact
- TypeScript
- Vitest
- npm workspaces + Turbo
The second submission takes the opposite shape: no frontend, no CLI — just one carefully written contract, a simulator, nine tests, and documentation that's better than most production repos. It's a permissioned (KYC-style) unshielded token: an owner administers an allowlist, and tokens can only be minted to or received by allowlisted accounts. The README explicitly mirrors the layout of our midnight-contracts monorepo so the workspace could be dropped straight in — a lovely touch.
The contract: small, idiomatic, and honest about its privacy model
Since Compact has no implicit msg.sender, callers prove who they are by knowing a secret key supplied through a witness, with the on-chain identity being a commitment:
export sealed ledger owner: Bytes<32>;
export pure circuit accountId(sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([pad(32, "allowlist:accountid:v1"), sk]);
}
circuit assertOwner(): [] {
assert(callerId() == owner, "AllowlistToken: caller is not the owner");
}
constructor() {
const deployer = callerId();
owner = disclose(deployer);
allowlist.insert(disclose(deployer), true);
}
The best-practice checklist basically writes itself here:
sealedused correctly — the owner is set once in the constructor and can never be reassigned.- Domain-separated identity derivation (
"allowlist:accountid:v1"), added in a dedicated commit after the cross-contract collision discovery mentioned above. - An explicit overflow guard on
mint(MAX_UINT64 - totalSupply >= amount) — the kind of check that's easy to skip in a sprint. - Deliberate
disclose()placement, including the deliberate absence of it inassertOwner()— asserting on a witness-derived value doesn't publish it, and the comments explain exactly that. - The privacy model is stated, not implied. The header says plainly: this is an unshielded token; balances, the allowlist, and account commitments are public; only the secret key behind each identity is private; value/owner privacy would need a Zswap design this contract intentionally doesn't attempt.
One design choice reviewers will notice: transfer gates the recipient on the allowlist but not the sender, so a de-listed holder can still send tokens out. The project's own docs.md lists fixing this as a reader exercise — so it's a documented scope decision, not an oversight — but for anything approaching a real regulated asset, we'd gate both ends.
Tests and documentation
The nine simulator tests cover every revert path by exact assertion message — non-owner rejection, mint to non-allowlisted, insufficient balance, post-removal mint — alongside the happy paths, with an invariant check that transfers don't change total supply. The simulator itself exposes the contract's pure circuit accountId via pureCircuits, so tests compute the same commitment off-chain that the contract derives on-chain — a small version of the cross-verification pattern we bake into the modular-starter's token tests.
And then there's docs.md: a 153-line beginner walkthrough of the contract — state, "who is the caller?", the disclose rule, the mint/isAllowed idiom, an explicit "what it does not do" section, and four extension exercises. This is education-grade material, and exactly the spirit of the sprint.
Improvements are mostly infrastructural: there's no CI at all (the well-configured Turbo graph — compact → build/test/lint, generated output gitignored — deserves a workflow that runs it), the simulator imports a fixture from the test file (a circular dependency; the fixture belongs in utils), the witnesses.ts comment still describes the pre-domain-separation hash, and the MAX_UINT64 overflow branch is the one revert path without a test. Longer-term, this contract is a natural candidate to become a reusable Compact module in the modular-starter style — it's already written like one.
What we loved
- Small, idiomatic contract: sealed owner, domain-separated commitments, overflow guard
- Nine simulator tests covering every revert path by exact message
- Off-chain/on-chain cross-verification of the accountId derivation
- docs.md is education-grade — the best documentation of the sprint
- Clean repo: correct Turbo graph, gitignored build output, pinned toolchain, honest scope
Level-up opportunities
- Add a CI workflow — the tests exist, nothing runs them
- Move the shared fixture out of the test file to break the simulator↔test cycle
- Gate the sender on the allowlist (or promote the docs.md exercise into the contract)
- Test the overflow branch; refresh the stale witnesses.ts comment
- Package it as a reusable module — it's one prefix import away
🥉 Third place: Spy
Nightforce Intelligence
3rd place · Spy
nightforce-intelligence
- React 19
- Vite 6
- TanStack Router
- Tailwind 4
- shadcn/ui
- pnpm + Turbo
Nightforce Intelligence is a spy-themed "private AI operations center": an AI chat codenamed Spy, a private preferences vault, a mission board with XP, and a reputation page with badge-based proof-of-contribution. The frontend craft is real — a coherent dark monospace theme carried across five pages, consistent wallet gating with connect prompts, sensible loading and empty states, and a counter page with a genuinely well-built four-stage transaction progress indicator driven off the provider's flow messages. As pure UI work, it's the most visually complete submission we reviewed.
The review has to be equally clear about the other half: the on-chain layer never left the starter template. The only contract in the repo is the stock nine-line counter:
export ledger round: Counter;
export circuit increment(): [] {
round.increment(1);
}
Nothing on-chain represents a vault entry, a mission, a badge, or a reputation proof — and the one page that does talk to the chain (/counter) isn't linked in the navigation, so every page a visitor can reach runs on mock state. A few of those mocks cross a line that matters on a privacy chain: the vault labels btoa()-encoded localStorage as AES-256-GCM encryption, the reputation page presents two seconds of setTimeout and a Math.random() hex string as a generated ZK proof, and the dashboard hardcodes "ZK Proofs: Ready." A deploy-time workaround also committed stubbed compiler output (Contract = class {}) so the published build can't interact with the chain at all.
The roadmap to make it real
The exciting part: Nightforce's feature set maps beautifully onto Compact, and the modular-starter's module pattern is the natural way to build it — one module per feature, composed into a single deployed contract:
- Vault → the flagship private-state use case. Keep preferences in witness-backed private state; put only commitments on-chain (
persistentHashwith a domain tag like"nightforce:vault:v1"), so the user can later prove facts about preferences without revealing them. - Missions → a
Map<Bytes<32>, Set<Uint<16>>>of completed mission ids per account commitment, with completion circuits validating the mission id — persistent XP instead of auseStatethat vanishes on reload. - Reputation → the real version of the badge proof: enroll contributors into a
MerkleTree, and let a member prove membership without revealing which member they are. That's an actual ZK proof-of-contributor, and it's well within reach — tminus1sec's nullifier work in this same sprint is most of the recipe.
The repo hygiene list is shorter: write a project README (the current one is the unmodified starter README — a reviewer landing on the repo can't tell what Nightforce is), add /counter to the nav, remove the committed managed/ stubs and let the build compile the contract for deploy, align DEPLOYMENT_PROCEDURE.md with what netlify.toml actually runs, turn off continue-on-error in CI, and replace the dangerouslySetInnerHTML markdown rendering in the chat with a safe renderer before any shared/backend content flows through it.
What we loved
- The most polished, coherent UI of the sprint — theme, states, and navigation feel finished
- Genuine custom work on the transaction progress flow for the counter page
- Consistent wallet gating across pages
- Clean secrets hygiene and a written deployment procedure
Level-up opportunities
- Build the contract layer — vault, missions, and reputation all map naturally onto Compact modules
- Label simulated features as simulated; remove the AES/ZK claims until they're real
- Link the contract page in the nav; un-stub the committed compiler output
- Write a Nightforce README; sync the deploy docs with netlify.toml
- Replace dangerouslySetInnerHTML in the chat with safe rendering
Cross-cutting lessons for the next sprint
Reviewing three repos side by side, the same themes kept surfacing — take these as the checklist for next time:
- Rename the scaffolding on day one. All three repos still say "counter" somewhere a judge will look: package names, routes, banners, READMEs. A thirty-minute rename makes a project read as yours.
- Make CI able to fail. Two repos had workflows where every step was
continue-on-error: true, and none ran the contract tests. A green check that can't turn red protects nothing — and the tests these teams wrote deserved enforcement. - Prefer commutative ledger operations for shared state. When multiple users write the same state concurrently,
Counter.incrementcomposes;lookup-then-insertcontends. Reach forCounter(including as aMapvalue withdefault<Counter>) andSetbefore hand-rolling read-modify-write. - Domain-separate every hash — the sprint proved why. The same author's two contracts briefly derived identical nullifiers from one key. Versioned domain tags (
"myapp:purpose:v1") are two tokens of code and the difference between isolated and linkable identities. - Document the privacy envelope honestly. The strongest submissions said exactly what was private, what was public, and what was out of scope. On Midnight, that candor is the engineering.
- Pin your toolchain.
compact compile +0.31.0, exact SDK versions,.node-version, pinned Docker tags. Every repo that did this was reproducible months later — this review depended on it. - Validate at the boundary. Circuit arguments are attacker-controlled inputs. Bounds-check ids, gate both ends of a transfer, and put the checks in the contract-level wrapper circuits where they belong.
Congratulations 🎉
To tminus1sec: two submissions that each model a different virtue — real privacy engineering with rare intellectual honesty, and a small library with documentation that teaches. To Spy: frontend craft that makes us want to see the on-chain half — the roadmap above is yours for the taking.
Both winners (and everyone else building on Midnight) should point their next project at the modular-starter: composable Compact modules, an SDK layer, reproducible deployments, and two-tier testing out of the box. We built it from everything this sprint taught us — the same material we use in our workshops and training.
Until the next one — keep it private, keep it honest. 🌙
Reviewing a Compact contract of your own? Talk to us →