100 days of midnight

Hey everyone :waving_hand: Nick here (Guto here and on Discord, @Godson_Kpp on X) — full-stack engineer studying, building, and writing about Midnight.

I’m running a public build log called 100 Days of Midnight: working through the stack layer by layer, documenting what I learn as I go, and converting the deeper dives into full technical writeups. Currently on Day 14.

Following along day-to-day? I’m also posting live on X: Igber Nicholas (@Godson_Kpp) / X

A quick catch-up on Days 1–13: started with the Wallet SDK and midnight-js fundamentals, moved through the Compact language and contract model, wrote and compiled a real contract, debugged through deployment (dust wallet SDK errors, export/disclose() issues) until I had a passing deploy test on local devnet, then covered the DApp Connector API and started digging into provider architecture.

Some background and progress along the way:

  • Nightroom — a privacy-preserving room rental protocol using ZK proofs, built before the challenge started and merged into midnightntwrk/midnight-awesome-dapps while I was mid-challenge
  • A NixOS troubleshooting guide, also merged into the ecosystem repo before the 100 Days started
  • Just published a technical deep-dive from within the challenge: Midnight Providers: What They Are, Where They Live, and Why We Use Them

I’ll be posting daily updates here as I go — real progress, real bugs, no polish. Happy to be around and glad to connect with other builders working through the same stack.

1 Like

Day 15/100 — From a loose thread to two verified issues and a fix

Back on Day 4, I noticed the Wallet SDK’s compatibility matrix only lists one combined version for “Wallet SDK” as a whole, with no way to resolve individual package versions from it. I logged it as a rough note and moved on.

Today I went back and traced it properly. Checked four sources: the docs release-notes page (only shows v1.0.0, v2.0.0, v3.0.0), the actual v1.2.0 release note in the midnight-wallet GitHub repo (complete, with every individual package version — just never published to the docs site), and both the old and new npm scopes to confirm which one is actually canonical.

Turned out to be two separate, precise bugs rather than one vague one:

#1237 — The v1.2.0 release notes page is missing entirely from docs.midnight.network, even though every other version and component follows a consistent one-page-per-version pattern.

#1238 — The Wallet Developer Guide still uses the deprecated @midnight-ntwrk (dashed) npm scope throughout the page — not just the install command, but the packages table and multiple code examples. The canonical scope as of 1.2.0 is @midnightntwrk (no dash).

#1238 was the more contained fix, so I went ahead and corrected it myself — updated every instance across the page, left @midnight-ntwrk/ledger-v8 untouched since it’s explicitly excluded from the scope migration, and opened a PR referencing the issue.

The bigger lesson: a “small” docs gap is sometimes actually two different problems wearing one description. Worth tracing all the way down before filing, rather than reporting the symptom.

Day 16/100 — BrowseMe: a README bug, a real deploy, and a wallet architecture lesson

Worked on BrowseMe today and made real progress on a few fronts.

Fixed a README bug: the contract compile command was documented as yarn compact instead of yarn compile. Small typo, but it meant the guide would fail outright for anyone following it on a fresh clone — worth catching before someone else hits the same wall.

Deployed the BrowseMe contract to devnet.

Learned a clean architectural distinction between two different ways of working with wallets: for local development and deployment where I need direct wallet operations myself, I initialize a local wallet on the CLI. But for a deployed dApp where end users need to connect their own browser wallet, the dApp wallet connector is the right tool instead. Different problem, different layer — not interchangeable.

Hit a “Failed to upsert history entry” error during deployment, traced to the absence of a local transaction history store. Not a real blocker — expected behavior for a one-off devnet deployment without persistent history wired up, not a bug in the deployment itself.

Day 17/100 — Wallet connector wired up, plus an important architectural distinction

Made real progress on BrowseMe’s frontend today.

Got the wallet connector fully wired up. Scaffolded the frontend directory — WalletCard.tsx, selectWallet.ts, types.ts — with a clean separation between the TypeScript interface, the presentational component, and the wallet-selection logic.

Understood something I’d been fuzzy on: the React wallet connector isn’t a separate thing from the DApp Connector API — it’s a wrapper around it. The Connector API is the actual standard interface for talking to a wallet; the React layer just gives you hooks and components on top of that.

Verified it actually works, not just renders: the “Connected” state now shows a real mn_addr_undeployed… address, meaning the wrapper is genuinely talking to the underlying Connector API and getting a real response back.

On the Day 15 PR (the @midnight-ntwrkmidnight-ntwrk vs @midnightntwrk scope question): had a review session today. Discussed directly on the PR, not resolved yet, still waiting on further discussion before it’s approved. Following it here: fix: update deprecated @midnight-ntwrk wallet-sdk scope to @midnightntwrk by Gutopro · Pull Request #1239 · midnightntwrk/midnight-docs · GitHub

One open snag: if the network type in my wallet selector doesn’t match my browser wallet’s actual network, the wallet selects successfully but no authorization button appears — the connector never reaches the permission-request step. Fails silently instead of throwing an error.

Day 18/100 — Wiring BrowseMe’s frontend to the Midnight stack

Today was about turning BrowseMe’s Midnight integration from scaffolding into something that actually connects a wallet, submits a transaction, and shows real state on screen.

Settled on a four-layer frontend architecture: React UI → Contract API class → Providers → Wallet + Indexer + Proof server. The Contract API class is a thin wrapper (deploy, join, submitTx, state) around the compiled contract that hides the Midnight.js SDK plumbing from the rest of the app’s components. The wallet bridge turns an already-connected DApp connector session into a clean interface the React layer can consume, and hooks/context subscribe components to that state.

One gotcha worth flagging for anyone hitting it later: your app’s keys/ and zkir/ need to be served as static files — usually copied into public/ at dev/build time. Skip that step and proof generation fails silently, or with a 404 on fetch, and neither error points you at the actual cause.

Also fixed a transaction history upsert error that had been nagging from earlier in the week — turned out to be a matter of wiring InMemoryTransactionHistory from wallet-sdk-abstractions properly into the wallet init config, and worked on wallet address encoding so the deploy wallet and browser wallet decode to the same address.

With that in place, built out the actual BrowseMe home page — “Own the deal. Keep the details private.” — positioning it as matching businesses with investors without exposing financials, identity, or negotiation details on the public ledger. Added a Connect Wallet card that hooks into the Midnight wallet and displays the connected address.

For test funding, clicked Connect Wallet on the home page to generate a real browser-wallet address, then used the local-dev CLI’s option [2], Fund accounts by public key (NIGHT transfer only), sending 50,000 NIGHT to that exact address. Transfer confirmed and showed up correctly in the explorer as unshielded NIGHT (Received/Self). Worth noting for anyone using this path: DUST is not registered automatically here — that’s a step the recipient has to do themselves, unlike the config-file funding option which registers it for you.

Tested the whole flow end to end: the same address connected via the home page’s Connect Wallet card is the one funded through the CLI and confirmed in the explorer — NIGHT received, DUST generating once registered.

Also spent some time in the Aliit builders hangout today, talking through the 100 Days challenge, a docs issue/PR I opened, and the lack of Midnight events on the African continent — moderated by Alex.

The bigger lesson from today: the silent-failure modes (missing static assets, unregistered DUST) are the ones that cost the most debugging time, because nothing actually errors — the app just quietly doesn’t work. Adding a loud startup check for the keys/zkir path next, and documenting the DUST registration gap clearly for anyone else funding test wallets this way.

Day 19/100 — Business registration UI, keeping most of it private

Built the business registration form UI for BrowseMe today.
Two registration tracks: Track A is for SMEs and formal businesses that can provide formal verification : tax ID, business registration number, and similar documentation so they can list immediately. Track B is for informal businesses that can’t offer that kind of formal verification, and instead build credibility through community attestations, including a union attestation, that need to clear a threshold before listing.
Privacy design: only sector, location, and a commitment hash go on-chain. Everything else, the specifics of the business, financials, identity details stays private until an actual deal is being negotiated. Checked this against Compact’s documented commitment pattern (persistentCommit): hash a value with a random salt, store the hash publicly, later prove properties about it or reveal it without ever having disclosed it upfront. That’s exactly the shape of what this form needs.
Also: updated the README to reflect the new frontend.
What’s not done yet: this is UI only right now, it’s not wired to the ContractApi layer, so there’s no working submit flow. That’s the next step.

Day 20/100 — Two real snags building the contract API wrapper

Currently building the contract API wrapper for BrowseMe, and hit two genuine environment-mismatch problems today.

Provider mismatch: My deploy script uses Node.js-specific providers (levelPrivateStateProvider and NodeZkConfigProvider ) because it runs from the CLI with a local seed wallet and reads ZK artifacts straight off the filesystem. The frontend can’t do that; browsers don’t have filesystem access, so it needs FetchZkConfigProvider instead, which fetches the same proving key, verifier key, and ZKIR over HTTP. Checked this against the docs confirmed it’s exactly the intended split: Node provider for CLI/server contexts, Fetch provider for browser contexts.

Commitment hash consistency: Registering an investor or a business (Track A or B) requires a bytes<32 > commitment hash computed client-side before the call goes out and I didn’t have a helper for that yet. This matters because the off-chain (client) and on-chain (circuit) commitment hashes have to be computed identically, using the same values and the same hashing approach, or the contract’s verification step will simply reject the submission. Need to build a shared helper so both sides stay in sync.

Building that commitment helper next, then wiring the frontend’s provider config to use FetchZkConfigProvider.

Day 21/100

Today after follow up on the pull request I made on day 15, which updated the deprecated @midnight-ntwrk npm scope used in the wallet sdk developer guide to use the canonical @midnightntwrk scope as per the wallet sdk version 1.2.0 release notes, was merged into Midnight’s official docs repo fix: update deprecated @midnight-ntwrk wallet-sdk scope to @midnightntwrk by Gutopro · Pull Request #1239 · midnightntwrk/midnight-docs · GitHub

I also learnt in the dev channel on discord that mobile on-device ZK proving is possible using the Kuira android sdk, it contains an embedded wallet, passkey identity and the compact runtime, the complete toolset for building with @MidnightNtwrk on android kuiralabs.github.io/kuira-sdk-andr… this means privacy and transparency is finally and truly in the hands of the end users.

Day 22/100 — Two frontend bugs, both confirmed against Midnight’s own examples

Ran into two real bugs getting the frontend running locally today.

Bug 1: blank page, EventEmitter undefined. DevTools showed the wallet SDK code failing because EventEmitter came through as undefined. Root cause: Vite doesn’t ship Node.js built ins to the browser by default, unlike Webpack, which polyfills them automatically. The wallet SDK expects Node’s “events” module, and Vite externalizes it for browser compatibility instead of bundling a substitute.

Fix: added vite plugin node polyfills. Confirmed this matches Midnight’s own wallet sdk repo exactly (apps/test website/vite.config.ts), and the repo’s README states directly: “Packaging for web requires polyfills for Node’s Buffer and assert.” Not a workaround, the expected, documented approach.

Bug 2: ZK artifacts not being served. Vite only serves static files that live in the project’s public/ directory.

Fix: copied the compiled ZK artifacts (keys/, zkir/) into my-wallet-app/public.

Day 23/100 — Got reviewed, found a snippet that wouldn’t run

Today’s entry isn’t new code, it’s a correction. On Day 14 I posted an article walking through Midnight’s provider layer, the modular pieces that handle proof generation, private state, public data, and transaction submission. @zsofimajor reviewed it today and caught something real.

The bug: the article’s final “putting it together” snippet assembled a MidnightProviders object with walletProvider and midnightProvider commented as `// from @midnight-ntwrkmidnight-ntwrk/wallet-sdk-facade`. That comment is wrong, and I’d carried it straight from one of Midnight’s own quick-start snippets without checking it. wallet-sdk-facade doesn’t export walletProvider or midnightProvider, it exports WalletFacade, a class. walletProvider and midnightProvider are interface-typed fields on MidnightProviders (the WalletProvider/MidnightProvider interfaces live in midnight-js-types), and you fill them yourself by wrapping whatever wallet or DApp Connector session you’re actually driving. As written, the snippet wouldn’t run. Anyone who copied it would get undefined references.

Fix: corrected the framing (interfaces you implement, not imports), and added two working adapters to back it up: a class-based version off the official bulletin board CLI tutorial, and a plain-object version wired against a browser DApp Connector session pulled from a working providers.ts in my own repo. Both compile, both copy-paste into a real project.

Smaller note from the review: I hadn’t pinned the SDK versions I was writing against. Package names and constructor signatures shift between Midnight releases, so I added a version note up top: @midnight-ntwrk/* at 4.1.1, wallet-sdk-facade at 4.0.1, with a line telling readers to pin their own.

Article’s updated with both adapters and the version note. Diff’s up if anyone wants to see exactly what changed.

Day 24/100 — WSL networking, Docker bind addresses, and a chain reset

Building BrowseMe on a mixed setup: frontend runs natively on Windows (React/Vite), backend (proof server, indexer, node) runs in Docker inside WSL.

The bug: clicking “Register business” failed with ERR_CONNECTION_RESET hitting localhost:6300/check, the proof server. Turned out to be two separate problems stacked on top of each other.

Problem one: my WSL build was old enough that it didn’t support mirrored networking, so Windows couldn’t see directly into WSL. Fixed by bridging with a netsh portproxy rule, forwarding localhost:6300 to the WSL VM’s internal IP.

Problem two: even with that in place, it kept resetting, because the midnight proof server container itself was only bound to 127.0.0.1:6300 inside WSL, not 0.0.0.0:6300. Nothing outside its own loopback could reach it, portproxy or not. Fixed the ports line in standalone.yml to ‘6300:6300’, force recreated the container (Docker only applies port changes on recreate, not restart), confirmed it came up bound to 0.0.0.0:6300, and registration started working.

Side effect: force recreating node and indexer too (neither has volume mounts) reset the local chain back to genesis. That broke my already running CLI process, which started spamming Wallet.Sync errors on a stale WebSocket connection, and required disconnecting and reconnecting my 1am wallet since it was holding a session tied to the now dead chain state.

Current bug: every button on the home page, regardless of which one, triggers the wallet connect flow instead of navigating anywhere. Digging into it now, likely a routing or click handler issue rather than anything backend related this time.

1 Like

Day 25/100 — Fresh clone fixed one bug and surfaced another, Track A registration works end to end
Real progress today, resolving yesterday’s open bug in an unexpected way.

Fixed the Day 24 bug: every button on the home page was triggering the wallet connect flow instead of navigating anywhere. The fix turned out to be a fresh clone of the BrowseMe repo, which cleared whatever stale state was causing the routing to misfire.

That same fresh clone surfaced a second issue: two things a fresh clone needs were missing. The .env.local file holding the deployed contract address, and the compiled ZK artifacts (keys/, zkir/) needed for proof generation.
Updated the README with the fresh clone steps: copy keys/ and zkir/ into the frontend’s public/ directory so Vite serves them as static files, and save the deployed contract address into an environment variable so ContractAPI can join the existing deployment instead of failing to find it.

Business registration (Track A) now works end to end. Screenshots confirm the full flow: wallet auth, balance and sign, submit transaction, then a “You’re registered” success screen.

Also: invited to speak at the Aliit builders hangout next Tuesday.


Day 26/100 — Closing out item one of a five-item review

Got a substantive review back on BrowseMe from Nasihudeen. Marked as a “fail,” but a genuinely useful one, the contract layer (attestation nullifiers, handshake logic, simulator tests) was called out as promising, with five specific, concrete things to fix before resubmit:

  1. Fix the frontend build, a fresh clone should pass npm run build
  2. Implement a real commitment scheme, App.tsx currently uses placeholderCommitment(), not a real hash
  3. Align privacy claims with on-chain reality, sector and location are fully disclosed today despite privacy claims
  4. Wire at least one more end-to-end flow beyond business registration
  5. Automate ZK artifact syncing so reviewers don’t hit an HTML-instead-of-key error

Today I closed out item 1.

The build was failing on TypeScript errors. Traced it to two stacked causes.

Problem 1: Yarn PnP silently hides installed dependencies from TypeScript. My repo mixes Yarn at the root with npm in the frontend. Yarn 4 defaults to Plug’n’Play mode, skipping a normal node_modules folder in favor of its own internal resolution. TypeScript doesn’t understand PnP, so a correctly installed dependency looked unresolvable. Fixed by setting nodeLinker: node-modules in .yarnrc.yml and reinstalling.

Problem 2: the compiled contract instance was missing its ZK assets. Confirmed against Midnight’s docs: a usable contract instance needs three things, contract code, witnesses, and compiled ZK assets attached via withCompiledFileAssets(...). I had the first two, not the third, which made the resulting TypeScript errors look like unrelated argument-shape problems rather than a missing setup step.

Verified: fresh clone now passes npm run build cleanly, and wallet connect plus a live registerBusiness transaction both work end to end against localnet.

Next: items 2 through 5, starting with the real commitment scheme, since it’s the one most directly tied to whether BrowseMe’s core privacy claim actually holds up.

Day 27/100 — Spoke at the Aliit builders hangout

Different kind of day today, spoke at the Aliit builders hangout, moderated by Zsófi. Got the invite based on where I sat on the leaderboard for the last sprint.

Walked through what I’ve been up to: BrowseMe, the docs PR that got merged, the technical article on Midnight providers, and the 100 Days of Midnight series itself, now 27 days in.

Good to say it out loud to a room of other builders rather than just posting it. The individual days, a bug fixed here, a claim verified there, don’t always feel like much on their own. Reporting them together made the actual shape of the last sprint clearer, to the room, and honestly to me too.

Back to the work tomorrow, items 2 through 5 on Nasihudeen’s review are still open.

Day 28/100 — Investor registration end-to-end, closing item 4

Wired up investor registration today: a form collecting company/business name, region, business ID, and tax ID, all private, hashed into a single commitment, calling the registerInvestor circuit. Screenshots confirm the full flow, same as business registration before it: wallet auth, balance and sign, submit transaction, “You’re registered” success screen. Dust deducted, NIGHT balance held steady, transaction actually settled.

Getting there wasn’t clean. Found a silent bug on the way: my submit handler was catching errors, logging them, setting a message in state, then resolving normally instead of re-throwing. The form only checks whether the promise it awaited rejected, so it always saw a clean resolution and jumped to the success screen regardless of whether anything had actually gone through. Fixed with throw in three places so failure actually propagates. Also closed a real dead-end while I was in there, added a way back to the homepage from a success screen.

This directly answers item 4 on Nasihudeen’s review, frontend only had business registration, now it has two working flows: registration and investor registration.

Items 2, 3, and 5 are still open, the commitment scheme is still placeholderCommitment(), not a real bound hash. The README doesn’t yet say plainly what’s public vs private on-chain. And ZK artifact sync into frontend/public/ still isn’t automated with a predev/prebuild hook. That’s the work for the next few days.

Day 29

Today was mostly debugging and closing out review items on BrowseMe.

Bug: StateValue instanceof mismatch on submit

registerBusiness was throwing “expected instance of StateValue” on transaction submit. Root cause: the frontend’s dependency tree resolved two different versions of @midnight-ntwrk/onchain-runtime-v3 — compact-runtime pulled 3.1.0, midnight-js-protocol pulled 3.0.0 — producing two separate WASM module instances and two incompatible StateValue classes. Fixed by pinning the version via npm overrides and doing a clean reinstall; confirmed a single deduped version and a single onchain_runtime_wasm_bg.wasm afterward.

Automated ZK artifact sync:

predev/prebuild hooks now copy contracts/managed/browseme/{keys,zkir} into frontend/public/ automatically before npm run dev or npm run build. No more manual copy step. Closes item 5 from Nasihudeen’s review.

Privacy claims aligned with on-chain reality:

Nasihudeen’s review flagged that sector and location are fully disclosed today, and the README wasn’t clear about what’s actually public vs private. Added a Privacy Model section with an explicit table, and softened the intro’s ZK-verification claim to reflect current status. Also documented, plainly, that the commitment scheme is still a placeholderCommitment() stand-in, so there’s no real on-chain binding of private fields yet, and the README no longer implies otherwise. Closes item 3 from the review.

Still pending:

- Real commitment scheme (item 2 from the review) — replacing placeholderCommitment() with something like an in-circuit persistentHash

With items 2, 3, and 4 closed today, only the commitment scheme implementation itself is left.

Day 30: Fixing Fake Commitments in BrowseMe

Today’s fix closed a real security hole. registerInvestor, registerBusinessTrackA/B, and submitAttestation were accepting a pre-hashed Bytes<32> straight from the caller and storing it as is. Nothing tied that value to actual private data. Anyone could submit an arbitrary 32-byte blob and the contract would treat it as a legitimate “commitment.” Naming a field commitment doesn’t make it one.

Why this matters more than it sounds: entropy

The deeper issue is entropy, how many possible values a piece of data could take. A random secret key has huge entropy; nobody brute-forces it. But form fields like investor type or funding range often come from a small, known set of options. That’s low entropy. If you hash a low-entropy value with a plain hash function, an attacker doesn’t need to break the hash. They just hash every possible option themselves and check which one matches what’s on-chain.

Two hash functions, two jobs

Compact’s standard library splits hashing into transient and persistent variants:

  • transientHash<T>(value): Field. Circuit-optimized, cheap, but not guaranteed to survive protocol upgrades. Good for temporary in-circuit checks only.
  • persistentHash<T>(value): Bytes<32>. SHA-256-based, stable across upgrades. Right for identities and nullifiers, but on its own, it hides nothing if the input space is small.

Two commitment schemes, same split, but with hiding and binding

  • transientCommit<T>(value, rand: Field): Field. Hides the input given sufficient randomness, but not durable across upgrades, so it shouldn’t be stored on-chain.
  • persistentCommit<T>(value, rand: Bytes<32>): Bytes<32>. The one BrowseMe needed. It gives two properties together: hiding (reveals nothing about the underlying value) and binding (can’t be swapped out after the fact). The random rand argument, the “opening,” is what defeats the brute-force-the-small-option-set attack, since guessing the value alone isn’t enough without also guessing the nonce.

The fix

Rewrote all three circuits so the private form comes in via witness, and the commitment is computed inside the circuit using persistentCommit<InvestorForm>(form, rand), then stored against the caller’s address in the ledger map.

All 7 circuits compiled clean, with the fixed registerInvestor, registerBusinessTrackA, registerBusinessTrackB, and submitAttestation circuits all producing valid proofs alongside the unchanged handshake circuits.

Lesson of the day: entropy determines whether a hash alone is enough. Low-entropy inputs need a commitment scheme, hash plus random nonce, not just a hash function, no matter how strong the underlying hash is.