DEV Community

Cover image for I Built a Midnight dApp Like an ETH Dev. Here's Everything I Got Wrong.

I Built a Midnight dApp Like an ETH Dev. Here's Everything I Got Wrong.

A brutally honest post-mortem on migrating from a server-managed wallet to the 1AM browser extension on the Midnight Network.

The Decision That Cost Me Two Days

I want to be specific about the moment I made the mistake, because it's a very recognizable moment.

Contracts written. Deployed on preprod. ZK proofs generating correctly through the CLI. I was looking at green checkmarks across the board and thinking: frontend time. The hard part was done.

That's the exact mental state where bad architectural decisions happen. You're riding the momentum of something working, and the last thing you want is to slow down and think carefully about something that feels like plumbing.

So I didn't. I looked at Midnight's SDK, saw a server-side wallet pattern that worked perfectly in Node.js, and made a decision I immediately told myself I'd "clean up later":

For simplicity, I'll just use a single server-side wallet for all transactions.

I've been writing software long enough to know that "clean up later" is a specific kind of lie developers tell themselves. But I told it anyway.

Later arrived. It cost me two days. Here's the full damage report.

What "Server Wallet" Actually Meant

Here's the thing about this mistake: it's completely understandable. If you come from Ethereum, you've probably written backend scripts that interact with contracts using a private key stored in a .env file. Hardhat tasks, deployment scripts, admin functions, server-side signing is normal. Expected, even.

Midnight has an equivalent pattern that works perfectly in Node.js environments. It goes something like this:

import { levelPrivateStateProvider } from '@midnight-ntwrk/midnight-js-level-private-state-provider';
import { NodeZkConfigProvider } from '@midnight-ntwrk/midnight-js-node-zk-config-provider';
import { httpClientProofProvider } from '@midnight-ntwrk/midnight-js-http-client-proof-provider';
import { WalletFacade } from '@midnight-ntwrk/wallet-sdk-facade';

const zkConfigProvider = new NodeZkConfigProvider(zkConfigPath);
const proofProvider = httpClientProofProvider('http://127.0.0.1:6300', zkConfigProvider);
const wallet = await WalletFacade(/* ... */);
Enter fullscreen mode Exit fullscreen mode

This is great for scripts. For CLI tools. For testing. It reads ZK artifacts from the filesystem, talks to a local proof server on port 6300, and persists private state using LevelDB.

What it is not is a browser-compatible architecture.

But I didn't think about that. I was in "get it working" mode. I stuffed this into my Next.js backend, created a server action that would sign and submit every transaction with my wallet, and called it done.

This worked. Demos looked fine. The contract calls went through. Nobody watching the demo could tell anything was architecturally broken.

That's the insidious part. The mistake is invisible until you try to ship to real users, and by that point, you've built an entire frontend on top of it.

Why It's Wrong (Beyond the Obvious "Users Can't Sign")

The most glaring problem is one everyone will call out immediately: users aren't signing their own transactions. That's not a dApp. That's a centralized backend with a ZK circuit in the middle. You've recreated a custodial wallet and called it Web3.

But there are deeper, more practical problems.

Problem 1: levelPrivateStateProvider doesn't exist in browsers.

LevelDB is a Node.js library. The moment you try to bundle this in a Next.js client component, you get a wall of webpack errors about missing native modules. You can't polyfill your way out of this.

Problem 2: NodeZkConfigProvider reads from the filesystem.

fs.readFileSync. In a browser. You know where this goes. The ZK artifacts (.verifier files and .bzkir files) need to be fetched over HTTP, not read from disk, but NodeZkConfigProvider doesn't know how to do that.

Problem 3: You're depending on a local proof server.

The httpClientProofProvider calls http://127.0.0.1:6300. That's a proof server that needs to be running locally. Your users don't have one. Your Vercel deployment definitely doesn't. And even if you ran it remotely, you'd immediately hit CORS hell trying to call it from the browser.

Problem 4: You hold all the keys.

Every transaction goes through your server wallet. Your seed phrase. Your keys. If your server goes down, the dApp breaks. If you rotate keys, every active session breaks. You've become the single point of failure for a supposedly trustless application.

I knew all of this, in theory. I just thought I'd "clean it up later."

Later arrived.

The Migration: What Actually Has to Change

The 1AM wallet is a browser extension for Midnight, think MetaMask but for ZK-native transactions. It injects a window.midnight['1am'] object and handles proof generation, transaction balancing, and signing internally.

To migrate, I had to rip out four things and replace them all.

1. ZK Config Provider

Before: NodeZkConfigProvider reading from filesystem.

After: FetchZkConfigProvider loading artifacts over HTTP.

const baseUrl = new URL('/contract/collection', window.location.origin).toString();
const zkConfigProvider = new FetchZkConfigProvider(baseUrl, window.fetch.bind(window));
Enter fullscreen mode Exit fullscreen mode

This expects your compiled artifacts to be served statically. The provider constructs URLs like:

  • {baseUrl}/keys/{circuitId}.verifier
  • {baseUrl}/zkir/{circuitId}.bzkir

So I had to compile my contracts and drop the output into public/contract/collection/. Then verify they were actually accessible:

curl http://localhost:3000/contract/collection/keys/mint.verifier
Enter fullscreen mode Exit fullscreen mode

If that returns HTML, you've got a path problem. If it returns binary data, you're good.

The gotcha I hit: I had my artifacts in the right directory but wrong structure. The provider expects keys/ and zkir/ subdirectories with specific naming. My files were dumped flat into the folder. This gave me a cryptic Failed to fetch ZK config for circuit 'mint' error that took me two hours to trace back to a missing subdirectory.

Two hours. For a missing folder. The error message gave no indication that the problem was directory structure, it just said fetch failed. This is a category of debugging that ages you.

2. Proof Provider

Before: httpClientProofProvider pointing to a local proof server.

After: The wallet's own proving capability.

const api = await wallet1AM.connect(networkId);
const provingProvider = await api.getProvingProvider(zkConfigProvider);

const proofProvider = {
  async proveTx(unprovenTx: any, _config: any) {
    const { CostModel } = await import('@midnight-ntwrk/ledger-v8');
    return unprovenTx.prove(provingProvider, CostModel.initialCostModel());
  },
};
Enter fullscreen mode Exit fullscreen mode

The 1AM extension bundles its own ZK prover. You don't need a proof server running anywhere. This is the cleanest part of the migration, once you have a connected wallet API, proof generation just works.

The dynamic import on CostModel is necessary. The ledger-v8 package ships a WASM module that Next.js tries to bundle at build time, and it fails. Dynamic import() defers it to runtime in the browser where WebAssembly is available. You'll still see warnings in your build logs about async/await not being supported in the target environment, those are non-blocking, but they're loud and they'll worry you the first time you see them.

I spent an embarrassing stretch of time trying to silence those warnings before accepting they were cosmetic. The build was fine. The app ran fine. I was just pattern-matching on red text in terminals. Move on faster than I did.

3. Private State Provider

Before: levelPrivateStateProvider with LevelDB persistence.

After: An in-memory map with contract address scoping.

function createPrivateStateProvider() {
  let contractAddressScope = '';
  const stateStore = new Map<string, any>();

  return {
    setContractAddress(address: string) {
      contractAddressScope = address;
    },
    async set(privateStateId: string, state: any) {
      stateStore.set(`${contractAddressScope}:${privateStateId}`, state);
    },
    async get(privateStateId: string) {
      return stateStore.get(`${contractAddressScope}:${privateStateId}`) ?? null;
    },
    // signing key methods...
  };
}
Enter fullscreen mode Exit fullscreen mode

The scoping by contract address is not optional. If you deploy multiple contracts in the same session without scoping, private state bleeds between them. I deployed two collection contracts during testing and couldn't figure out why the second one was behaving like the first, private state was resolving to the wrong contract's data. This one took longer to diagnose than I'd like to admit, because "state leaking between contracts" isn't where your brain goes first when a minting flow misbehaves.

The honest trade-off: this is ephemeral. Page reload wipes it. For production you'd want IndexedDB or syncing with the wallet's own private state store. That's a future problem. Right now, scoped in-memory state is correct enough to ship and test against real users.

4. Transaction Balancing and Submission

This is the most finicky part and where most of the debugging time went.

Before: WalletFacade.balanceTx() returned a Transaction object directly.

After: api.balanceUnsealedTransaction() returns a hex string. You have to deserialize it yourself.

const walletProvider: WalletProvider = {
  balanceTx: async (tx: any) => {
    const txHex = toHex(tx.serialize());
    const balanced = await api.balanceUnsealedTransaction(txHex);

    // The wallet returns { tx: "hexstring" } — you must deserialize
    const { Transaction } = await import('@midnight-ntwrk/ledger-v8');
    const bytes = new Uint8Array(
      balanced.tx.match(/.{2}/g).map((b: string) => parseInt(b, 16))
    );
    return Transaction.deserialize('signature', 'proof', 'binding', bytes);
  },
  getCoinPublicKey: () => shieldedAddress.shieldedCoinPublicKey,
  getEncryptionPublicKey: () => shieldedAddress.shieldedEncryptionPublicKey,
};
Enter fullscreen mode Exit fullscreen mode

The error I got before adding the deserialization step was Error: balanceUnsealedTransaction returned invalid result. Not "type mismatch," not "expected Transaction object, received string." Just "invalid result." I stared at that error for longer than I should have before realizing the wallet was returning hex and I was passing it somewhere that expected a typed object.

Good error messages would have made this a 5-minute fix. It was not a 5-minute fix.

For submission:

const midnightProvider: MidnightProvider = {
  submitTx: async (tx: any) => {
    const txHex = toHex(tx.serialize());
    const result = await api.submitTransaction(txHex);

    // Handle multiple possible return shapes across wallet versions
    return (typeof result === 'string' 
      ? result 
      : result?.transactionId || result?.id) || '';
  },
};
Enter fullscreen mode Exit fullscreen mode

The defensive extraction on transactionId || id exists because different versions of the 1AM extension return different shapes. You'll only discover which one you have when your transaction ID comes back as undefined in production.

The Race Condition Nobody Warns You About

Browser extensions load asynchronously. Your app boots, checks window.midnight['1am'], finds nothing, and reports "wallet not found", even though the extension is installed and working.

You need to poll:

const DETECT_TIMEOUT_MS = 6000;
const DETECT_INTERVAL_MS = 300;

const startedAt = Date.now();
const intervalId = setInterval(() => {
  const wallet = (window as any).midnight?.['1am'];

  if (wallet) {
    clearInterval(intervalId);
    // proceed with connection
    return;
  }

  if (Date.now() - startedAt >= DETECT_TIMEOUT_MS) {
    clearInterval(intervalId);
    // show "install wallet" message
  }
}, DETECT_INTERVAL_MS);
Enter fullscreen mode Exit fullscreen mode

Six seconds is generous but reasonable. The extension usually loads within one or two. If it's not there after six, the user genuinely doesn't have it installed.

Also support Lace wallet as a fallback, window.midnight.mnLace, and consider a seed phrase input mode for CLI users and recovery scenarios. Real users will be less technical than you. Having multiple connection paths matters.

The Full Diff: What the Architecture Actually Looks Like Now

The server wallet model had everything in a single Node.js process. One wallet, one proof server, one private state database. Simple to reason about, impossible to scale to real users.

The browser wallet model distributes responsibility correctly:

User's Browser
├── 1AM Extension
│   ├── User's private keys (never leaves extension)
│   ├── Built-in ZK prover
│   └── Transaction signing
│
└── Your Next.js App
    ├── FetchZkConfigProvider (loads artifacts from /public)
    ├── In-memory PrivateStateProvider (scoped per contract)
    └── WalletProvider / MidnightProvider (delegates to extension)
Enter fullscreen mode Exit fullscreen mode

The app never touches private keys. The user signs everything. The proof server is gone. The LevelDB dependency is gone. CORS is gone.

What I'd Tell Myself If I Could Go Back

Don't prototype with server wallets. The migration cost is real. Every abstraction you build around the server wallet pattern has to be unwound. If I'd started with the 1AM integration from day one, even just stubbed out, the final code would have been cleaner and I'd have caught the ZK artifact path issues during initial development rather than during migration.

Read the provider interfaces, not the implementations. The SDK exports clean interfaces for WalletProvider, MidnightProvider, ProofProvider. If I'd read those first instead of copying the working server-side implementations, I'd have understood earlier that these are swappable, and that swapping them is the whole point.

Browser-first is table stakes. You know this from Ethereum. MetaMask was non-negotiable in 2019. Apply the same instinct to Midnight immediately, don't let the novelty of ZK circuits make you forget the basics.

Scope your private state by contract address on day one. The fix is a single string prefix. The debugging session when you skip it is not.

The Honest Part Nobody Writes

Here's the thing I kept thinking about while doing this migration:

The error messages in Midnight's SDK are often terrible. Not "could be better", genuinely unhelpful in ways that send you in the wrong direction. balanceUnsealedTransaction returned invalid result tells you nothing about what was actually invalid. Failed to fetch ZK config for circuit 'mint' gives you zero signal about whether the problem is the URL, the directory structure, the file format, or something else entirely.

This is a real cost. Not a dealbreaker, but a real cost, and one that compounds. Every cryptic error is an hour of debugging that a better message would have made five minutes. When you're building alone at 3 AM and you've been staring at the same error for three hours, "invalid result" starts to feel personal.

I'm writing this down because the Midnight team is actively building, they read what builders publish, and error message quality is a low-effort, high-leverage improvement. The underlying architecture is sound. The ZK primitives are genuinely interesting. But right now, the gap between "this works in CLI" and "this works in a user-facing browser app" is navigated almost entirely by developer pain, and it doesn't have to be.

If this post exists in the Midnight documentation six months from now as a reference for the migration path, that's a good outcome. The patterns above are correct. The architecture works. The error messages that led me to those patterns were not helpful, and I'd rather name that honestly than wrap this up with a cheerful "but overall it was worth it!"

It was worth it. The honest version of that statement includes everything above.

Tushar Pamnani — building at Sevryn Labs. Find me on X or GitHub. Writing about Midnight at dev.to/tusharpamnani.

If you're starting your Midnight journey and want to skip some of these walls, reach out. The repos are open.

Top comments (0)