DEV Community

Tosh
Tosh

Posted on

Connecting a Browser dApp to Midnight Wallets: The DApp Connector API

Connecting a Browser dApp to Midnight Wallets: The DApp Connector API

I spent a couple of afternoons wiring up my first Midnight dApp and hit most of the rough edges so you don't have to. The wallet connection model is genuinely different from what you're used to in EVM land, and the naming in certain parts of the API is, uh, ambitious. Let me walk you through it.

What We're Building

I'll cover detecting available Midnight wallets (Lace and 1AM), connecting to one, submitting a contract transaction using wallet-side ZK proving, and subscribing to on-chain state changes. All the code targets @midnight-ntwrk/dapp-connector-api v4.x.

The window.midnight Object

EVM wallets inject window.ethereum. Midnight wallets inject under window.midnight, but the structure is different — it's a flat object where each key is an arbitrary UUID string, and each value is an InitialAPI instance.

import type { InitialAPI } from '@midnight-ntwrk/dapp-connector-api';

// The global type is declared by the package — just import it
// and window.midnight will be typed automatically.
import '@midnight-ntwrk/dapp-connector-api';

function getAvailableWallets(): InitialAPI[] {
  if (!window.midnight) {
    return [];
  }
  return Object.values(window.midnight);
}
Enter fullscreen mode Exit fullscreen mode

InitialAPI has the properties you need to build a wallet picker UI:

type InitialAPI = {
  rdns: string;       // e.g. "io.lace.midnight" — stable identifier
  name: string;       // display name, sanitize before rendering
  icon: string;       // URL or base64 data URI — use <img> not innerHTML
  apiVersion: string; // semver of the connector API implementation
  connect: (networkId: string) => Promise<ConnectedAPI>;
};
Enter fullscreen mode Exit fullscreen mode

A quick note on rdns: it follows reverse-DNS notation and is stable across versions. Use it when you need to remember which wallet a user previously connected to. Don't hard-code a specific value and reject unknown wallets — treat it like a user-agent string.

Rendering the Picker Safely

The spec explicitly calls out XSS risks. Wallets inject their own name and icon fields — you don't control those values.

function renderWalletOption(wallet: InitialAPI, container: HTMLElement): void {
  const item = document.createElement('div');
  item.className = 'wallet-option';

  const img = document.createElement('img');
  img.src = wallet.icon; // Safe: src attribute, not innerHTML
  img.alt = '';

  const label = document.createTextNode(wallet.name); // Safe: text node

  item.appendChild(img);
  item.appendChild(label);
  item.addEventListener('click', () => connectWallet(wallet));
  container.appendChild(item);
}
Enter fullscreen mode Exit fullscreen mode

Never do this:

container.innerHTML += `<img src="${wallet.icon}">`; // DO NOT DO THIS
Enter fullscreen mode Exit fullscreen mode

A malicious wallet extension could inject script tags through wallet.icon.

Connecting

import type { ConnectedAPI } from '@midnight-ntwrk/dapp-connector-api';

async function connectWallet(wallet: InitialAPI): Promise<ConnectedAPI> {
  // Use 'mainnet' for production. 'testnet-02' for the current testnet.
  const api = await wallet.connect('testnet-02');
  return api;
}
Enter fullscreen mode Exit fullscreen mode

connect() triggers the wallet's permission prompt. The promise resolves once the user approves — or throws an APIError if they reject.

Handling Connection Errors

import { ErrorCodes, type APIError } from '@midnight-ntwrk/dapp-connector-api';

function isDAppConnectorError(err: unknown): err is APIError {
  return (
    typeof err === 'object' &&
    err !== null &&
    (err as any).type === 'DAppConnectorAPIError'
  );
}

async function safeConnect(wallet: InitialAPI): Promise<ConnectedAPI | null> {
  try {
    return await wallet.connect('testnet-02');
  } catch (err) {
    if (isDAppConnectorError(err)) {
      switch (err.code) {
        case ErrorCodes.Rejected:
          console.log('User declined the connection request.');
          break;
        case ErrorCodes.InternalError:
          console.error('Wallet internal error:', err.reason);
          break;
        default:
          console.error('Unexpected connector error:', err.code, err.reason);
      }
    }
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

One thing that surprised me: APIError doesn't extend the base Error class in a way that makes instanceof APIError reliable across extension boundaries. The type === 'DAppConnectorAPIError' check is the canonical way to detect these errors. The package docs mention this explicitly.

Version Mismatch

If wallet.apiVersion is a version your code doesn't support, bail early:

import { satisfies } from 'semver';

function isCompatible(wallet: InitialAPI): boolean {
  // Require at least v4.0.0
  return satisfies(wallet.apiVersion, '>=4.0.0');
}
Enter fullscreen mode Exit fullscreen mode

Wallets can inject multiple InitialAPI instances under different keys for backward compatibility — if you see two entries with the same rdns, pick the one matching your required version.

Getting Wallet Info After Connection

Once connected, you have a ConnectedAPI with the full wallet surface:

async function showWalletInfo(api: ConnectedAPI): Promise<void> {
  const [shielded, unshielded, dust] = await Promise.all([
    api.getShieldedBalances(),
    api.getUnshieldedBalances(),
    api.getDustBalance(),
  ]);

  console.log('Shielded balances:', shielded);
  console.log('Unshielded balances:', unshielded);
  console.log(`Dust: ${dust.balance} / ${dust.cap} (balance / cap)`);

  const { shieldedAddress } = await api.getShieldedAddresses();
  const { unshieldedAddress } = await api.getUnshieldedAddress();

  console.log('Shielded address:', shieldedAddress);
  console.log('Unshielded address:', unshieldedAddress);
}
Enter fullscreen mode Exit fullscreen mode

Addresses come back in Bech32m format. getDustBalance() returning both balance and cap is worth understanding: Dust is generated from Night tokens over time, and cap represents the maximum Dust you can accumulate from your current Night holdings. If balance === cap, you're topped up.

The Transaction Flow: Browser vs. CLI

This is where Midnight diverges most sharply from EVM development, and where the naming trips people up.

In a Node.js script or CLI tool, the typical flow is:

  1. Build an unproven transaction from your contract call
  2. Send it to an external proof server to generate ZK proofs → get back a proven, unbalanced transaction
  3. Send that to a wallet (or use local keys) to balance and sign it
  4. Submit to the network

In a browser dApp with a wallet extension, step 2 changes: instead of hitting a remote proof server, you delegate proving to the wallet itself. The wallet runs the ZK proof generation in the browser using WebAssembly. This is the flow the issue tutorial calls "balanceAndProveTransaction" — it's not a single method, but a combined pattern using getProvingProvider.

Setting Up Wallet-Side Proving

The @midnight-ntwrk/midnight-js-dapp-connector-proof-provider package wraps the wallet's proving capability into a ProofProvider that the Midnight.js contract API understands:

import { dappConnectorProofProvider } from '@midnight-ntwrk/midnight-js-dapp-connector-proof-provider';
import { CostModel } from '@midnight-ntwrk/ledger-v8';
import type { ZKConfigProvider } from '@midnight-ntwrk/midnight-js-types';

async function buildProviders(
  api: ConnectedAPI,
  zkConfigProvider: ZKConfigProvider<string>,
) {
  // This asks the wallet to prepare its WASM proving engine.
  // The first call can take a few seconds — the wallet is loading ~50MB of ZK keys.
  const proofProvider = await dappConnectorProofProvider(
    api,
    zkConfigProvider,
    CostModel.initial(),
  );

  return { proofProvider };
}
Enter fullscreen mode Exit fullscreen mode

Under the hood, dappConnectorProofProvider calls api.getProvingProvider(keyMaterialProvider), which tells the wallet what ZK circuit keys it needs. The wallet handles downloading and caching those keys — your dApp doesn't need to ship them.

This is the fundamental difference from the CLI flow. In CLI/Node.js, your ProofProvider is an HTTP client pointing at a prover server you run separately. In the browser, dappConnectorProofProvider wraps the wallet's built-in WebAssembly prover. Same ProofProvider interface, completely different execution environment.

Balancing the Transaction

After proving, you still need to balance the transaction — add inputs/outputs to cover fees and any token movements. The wallet handles this too:

import type { UnprovenTransaction } from '@midnight-ntwrk/midnight-js-types';

async function proveAndBalance(
  api: ConnectedAPI,
  proofProvider: ProofProvider,
  unprovenTx: UnprovenTransaction,
): Promise<string> {
  // Step 1: Prove (runs in wallet's WASM environment)
  const provenUnbalancedTx = await proofProvider.proveTx(unprovenTx);

  // Step 2: Balance (wallet adds fee inputs, change outputs, signs)
  // provenUnbalancedTx is serialized to a string here (base64 encoded bytes)
  const serialized = serializeTransaction(provenUnbalancedTx);
  const { tx: balancedTx } = await api.balanceUnsealedTransaction(serialized);

  return balancedTx;
}
Enter fullscreen mode Exit fullscreen mode

balanceUnsealedTransaction expects a Transaction<SignatureEnabled, Proof, PreBinding> in serialized form. "Unsealed" means the binding signature hasn't been applied yet — the wallet adds it during balancing. There's also balanceSealedTransaction for already-signed transactions, but the unsealed variant is what you want for contract interactions where the wallet might need to merge intents.

Submitting

async function submit(api: ConnectedAPI, balancedTx: string): Promise<void> {
  try {
    await api.submitTransaction(balancedTx);
    console.log('Transaction submitted');
  } catch (err) {
    if (isDAppConnectorError(err)) {
      if (err.code === ErrorCodes.Disconnected) {
        // Wallet was closed or the extension reloaded mid-flow.
        // Prompt the user to reconnect and retry.
        throw new Error('Wallet disconnected during submission. Please reconnect.');
      }
    }
    throw err;
  }
}
Enter fullscreen mode Exit fullscreen mode

submitTransaction uses the wallet as a relayer to the Midnight network. The transaction must be balanced and sealed at this point. You get no return value — to know if your transaction landed, you watch the indexer.

Subscribing to Wallet State Changes

Real-time updates in Midnight dApps come from the indexer, not from a polling mechanism on ConnectedAPI. The PublicDataProvider interface (from @midnight-ntwrk/midnight-js-types) exposes RxJS observables for contract state changes.

First, get the indexer endpoint from the wallet's configuration — this respects any endpoint preferences the wallet user has set:

async function getIndexerConfig(api: ConnectedAPI) {
  const config = await api.getConfiguration();
  return {
    indexerUri: config.indexerUri,
    indexerWsUri: config.indexerWsUri,
    networkId: config.networkId,
  };
}
Enter fullscreen mode Exit fullscreen mode

Then subscribe to contract state changes:

import { contractStateObservable } from '@midnight-ntwrk/indexer-public-data-provider';
import type { ContractState } from '@midnight-ntwrk/compact-runtime';
import type { ContractAddress } from '@midnight-ntwrk/ledger-v8';
import { Subscription } from 'rxjs';

function watchContractState(
  publicDataProvider: PublicDataProvider,
  contractAddress: ContractAddress,
  onUpdate: (state: ContractState) => void,
): Subscription {
  return publicDataProvider
    .contractStateObservable(contractAddress, { type: 'latest' })
    .subscribe({
      next: onUpdate,
      error: (err) => console.error('State stream error:', err),
    });
}
Enter fullscreen mode Exit fullscreen mode

The { type: 'latest' } config tells the observable to start with the most recent on-chain state and stream all updates forward. Use { type: 'all' } if you need to replay the full state history, or { type: 'txId', txId: '...' } to start from a specific transaction.

Clean up subscriptions when the user navigates away:

let sub: Subscription | null = null;

function startWatching(provider: PublicDataProvider, address: ContractAddress) {
  sub = watchContractState(provider, address, (state) => {
    // Update your UI here
    renderContractState(state);
  });
}

function stopWatching() {
  sub?.unsubscribe();
  sub = null;
}
Enter fullscreen mode Exit fullscreen mode

Missing that unsubscribe() call is the most common source of memory leaks in Midnight dApps. The observable holds a WebSocket connection open — if you let components mount and unmount without cleaning up, you'll accumulate connections until things start breaking.

Connection Status and Reconnection

The wallet can disconnect while your dApp is open — the user closes the extension popup, the extension updates, or the tab was backgrounded long enough for the browser to evict the service worker.

async function checkConnection(api: ConnectedAPI): Promise<boolean> {
  const status = await api.getConnectionStatus();
  return status.status === 'connected';
}

// Poll or call this before any wallet operation in a long-lived session
async function ensureConnected(
  wallet: InitialAPI,
  currentApi: ConnectedAPI | null,
): Promise<ConnectedAPI> {
  if (currentApi) {
    const still = await checkConnection(currentApi);
    if (still) return currentApi;
  }
  // Reconnect — this will trigger the wallet permission prompt again
  return await wallet.connect('testnet-02');
}
Enter fullscreen mode Exit fullscreen mode

There's no event-based disconnection notification in the current API. You find out when a call throws ErrorCodes.Disconnected. Build your error handlers to detect this and offer reconnection rather than a generic "something went wrong" message.

Permission Hinting

One feature that's easy to miss: hintUsage. Before you begin a user flow that needs specific wallet capabilities, you can tell the wallet upfront:

await api.hintUsage(['getShieldedBalances', 'balanceUnsealedTransaction', 'submitTransaction']);
Enter fullscreen mode Exit fullscreen mode

The wallet can use this to batch permission requests into a single prompt instead of interrupting the user at each step. Not all wallets act on these hints, but it's a no-op if they don't, so there's no reason to skip it.

Putting It Together

Here's a minimal end-to-end example showing the full connect → prove → balance → submit flow:

import '@midnight-ntwrk/dapp-connector-api';
import { ErrorCodes, type ConnectedAPI, type InitialAPI } from '@midnight-ntwrk/dapp-connector-api';
import { dappConnectorProofProvider } from '@midnight-ntwrk/midnight-js-dapp-connector-proof-provider';

async function runTransaction(
  wallet: InitialAPI,
  zkConfigProvider: ZKConfigProvider<string>,
  buildUnprovenTx: (api: ConnectedAPI) => Promise<UnprovenTransaction>,
): Promise<void> {
  // 1. Connect
  const api = await wallet.connect('testnet-02');

  // 2. Hint what we'll need
  await api.hintUsage(['balanceUnsealedTransaction', 'submitTransaction']);

  // 3. Build wallet-backed proof provider
  const proofProvider = await dappConnectorProofProvider(
    api,
    zkConfigProvider,
    CostModel.initial(),
  );

  // 4. Build unproven transaction from your contract call
  const unprovenTx = await buildUnprovenTx(api);

  // 5. Prove (wallet runs ZK proof in browser WASM)
  const provenUnbalancedTx = await proofProvider.proveTx(unprovenTx);

  // 6. Balance and seal (wallet adds fees, signs)
  const { tx: balancedTx } = await api.balanceUnsealedTransaction(
    serializeTransaction(provenUnbalancedTx),
  );

  // 7. Submit via wallet
  await api.submitTransaction(balancedTx);
}
Enter fullscreen mode Exit fullscreen mode

The key thing to internalize: proving and balancing are two separate wallet calls that you perform in sequence. The wallet's WASM prover is the reason this works in a browser without a proof server — but it also means step 5 can take 10-30 seconds on first use while the wallet initializes its proving keys. Cache the proofProvider instance across calls; don't recreate it on every transaction.

Common Failure Modes

"No wallet detected"window.midnight is undefined or empty. Either the user doesn't have a Midnight wallet installed, or the extension hasn't loaded yet (can happen on fast initial page loads). Render a "Install Lace or 1AM wallet" message with links rather than silently breaking.

PermissionRejected on getProvingProvider — The wallet requires explicit user approval before it hands over proving capabilities. Some wallet versions prompt for this separately from the initial connect(). Catch it and explain to the user what they're being asked to approve.

InvalidRequest on balanceUnsealedTransaction — Usually means the serialized transaction format is wrong. This happens when you try to pass the transaction object directly instead of serializing it first, or when you use a ledger version mismatch between your dApp and the wallet. Double-check that your @midnight-ntwrk/ledger-v8 version matches what the wallet was built against.

Proving takes forever — The WASM proving keys are large (~50MB). On first connection in a given browser profile, the wallet needs to download and cache them. Subsequent calls are fast. Add a loading state that mentions this delay so users don't think the page is broken.

State subscription stops emitting — Usually a dropped WebSocket connection to the indexer. Implement an error handler on your observable and re-subscribe with exponential backoff.

What's Different from EVM

If you came to this from ethers.js or wagmi, the mental model is genuinely different and worth mapping explicitly.

The wallet isn't just a signer — it's the proving engine, the balancer, and the submitter. Your dApp orchestrates the sequence but never holds private key material.

ZK proof generation happens locally in the wallet, not in your dApp. You pass the wallet circuit key material (via ZKConfigProvider), and it handles the actual proof. The contract's ZK artifacts don't live in your bundle.

Contract state doesn't come from the wallet at all — it comes from the indexer, via PublicDataProvider. The wallet only knows about transactions it participated in. This surprised me. I expected the wallet to be the source of truth for "what's happening on chain," like MetaMask is for EVM state. It's not.

That separation — wallet for proving/signing, indexer for reading — is the mental model shift that took me longest to internalize. Once it clicks, the rest of the API makes sense.


Ready for review

Top comments (0)