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);
}
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>;
};
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);
}
Never do this:
container.innerHTML += `<img src="${wallet.icon}">`; // DO NOT DO THIS
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;
}
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;
}
}
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');
}
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);
}
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:
- Build an unproven transaction from your contract call
- Send it to an external proof server to generate ZK proofs → get back a proven, unbalanced transaction
- Send that to a wallet (or use local keys) to balance and sign it
- 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 };
}
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;
}
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;
}
}
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,
};
}
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),
});
}
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;
}
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');
}
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']);
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);
}
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)