DEV Community

Cover image for I Spent Hours in the DOM So You Don't Have To

I Spent Hours in the DOM So You Don't Have To

Full source: github.com/tusharpamnani/midnight-wallet-kit

When I started building frontends on Midnight, I hit a wall that nobody warned me about.

The contracts were working. The proof server was running. The TypeScript SDK was integrated. And then I needed to connect a wallet.

There was no proper documentation for Lace's Midnight provider. No fixed window.ethereum-style standard to follow. Just two browser extensions injecting objects into the DOM under different keys, with different method signatures, inconsistent behavior, and no specification to read.

I spent hours inspecting window objects, console-logging provider payloads, and reverse-engineering what each wallet actually exposed before I could make a single connection work reliably. Every Midnight frontend developer hits this exact wall. Nobody should have to climb it twice.

That's why I built midnight-wallet-kit.

What the Problem Actually Looks Like

If you've built on Ethereum, you know window.ethereum. It's a standard. You call eth_requestAccounts, you get addresses, you sign. Every wallet implements the same interface.

Midnight doesn't have this yet. Lace injects under window.lace. 1AM injects under window.midnight. The methods exist but aren't documented. The payload shapes vary. And because Midnight uses ZK proofs and shielded transactions, the signing flow is more complex than EVM; you're not just signing a hash, you're signing an intent that the proof server will process.

Without a proper abstraction layer, every developer ends up writing the same fragile, bespoke integration code:

// What you end up writing without a kit
const provider = (window as any).lace?.midnight;
if (!provider) throw new Error("Lace not found?");
const accounts = await provider.enable?.();
// hope this works, documentation doesn't exist
Enter fullscreen mode Exit fullscreen mode

Then you do the same thing for 1AM. Then you handle the case where neither is installed. Then you handle session persistence across page refreshes. Then you write tests that require a real browser extension to be installed. By the time you're done, you've written a wallet library, except it only works for your specific app and breaks when the extension updates.

What Midnight Wallet Kit Gives You

midnight-wallet-kit is a production-grade abstraction layer for Lace and 1AM on Midnight. Install it, register your adapters, wrap your app in a provider, and you're done.

npm install midnight-wallet-kit
Enter fullscreen mode Exit fullscreen mode

Setup

import { WalletManager, InjectedWalletAdapter } from 'midnight-wallet-kit';

const manager = new WalletManager();

manager
  .register(new InjectedWalletAdapter({ name: 'Lace', providerKey: 'lace' }))
  .register(new InjectedWalletAdapter({ name: '1AM', providerKey: 'midnight' }));
Enter fullscreen mode Exit fullscreen mode
import { WalletProvider } from 'midnight-wallet-kit/react';

function App({ children }) {
  return (
    <WalletProvider
      manager={manager}
      autoConnect={['1AM', 'Lace']}  // priority fallback order
      autoRestore={true}              // reconnect last-used wallet on refresh
    >
      {children}
    </WalletProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

That's the entire integration. Everything below this point is application code.

The Four Hooks

useWallet(): connection state

const { address, isConnected, connectionState, error } = useWallet();
Enter fullscreen mode Exit fullscreen mode

connectionState gives you the full lifecycle: idle → connecting → connected, with restoring, error, disconnecting, and disconnected as intermediate states. No more boolean isConnected that doesn't tell you why a connection failed.

address returns the user's Midnight unshielded address — the same mn_addr_... format used throughout the contract layer. coinPublicKey and encryptionPublicKey are also available if your dApp needs them.

useConnect(): connection management

const { connect, disconnect, isLoading, adapters } = useConnect();

// Connect to a specific wallet
await connect('1AM');

// Or let the kit try in priority order
// (handled automatically via WalletProvider autoConnect)
Enter fullscreen mode Exit fullscreen mode

adapters gives you the list of all registered adapter names; useful for rendering a wallet selection UI without hardcoding names.

useIntent(): signing

const { buildAndSign, signMessage } = useIntent();

// Sign a contract intent
const result = await buildAndSign({
  contractAddress: '...',
  circuitId: 'buy',
  args: [n, maxCost]
});

// Sign an arbitrary message (login flows, proofs of ownership)
const signed = await signMessage("Login to My DApp");
// Timestamping and normalization handled automatically
Enter fullscreen mode Exit fullscreen mode

buildAndSign validates intent parameters with Zod before sending anything to the wallet; you get a typed InvalidIntentError before the extension even opens, not a cryptic failure deep in the proof pipeline.

signMessage handles the multi-step probing for data-signing support across different wallet versions, adds proper prefixes, and generates unique timestamps automatically.

useBalance(): balance polling

const { balance, isLoading, error, refetch } = useBalance();
// balance: { tDUST: bigint; shielded: bigint } | null
Enter fullscreen mode Exit fullscreen mode

Automatically polls every 15 seconds when connected. refetch() is available for manual triggers after a transaction. Uses the Midnight indexer under the hood; if the indexer query fails, you get a typed BalanceFetchError, not a silent null.

The Architecture: Why It's Built This Way

Adapters normalize the provider chaos

Every wallet integration lives in an adapter that implements MidnightWallet. The InjectedWalletAdapter handles the DOM-level provider probing, the part I spent hours figuring out manually. It exhaustively searches for working RPC methods across different provider standards and payload formats, so if Lace updates their injection key or 1AM changes their method signature, you update one adapter, not every component in your app.

Wallet modes are explicitly typed:

  • intent-signing: supports the full signIntent() flow, standard for DApps
  • tx-only: direct transaction balancing and submission only
  • unknown: handled defensively as a fallback

WalletManager handles the lifecycle

The WalletManager is the orchestrator. It manages adapter registration, connection state transitions, fallback chains, middleware, and session persistence.

Fallback chains are the feature I wish I'd had from day one:

await manager.connectWithFallback(['1AM', 'Lace']);
Enter fullscreen mode Exit fullscreen mode

Try 1AM first. If it's not installed or the user rejects, try Lace. If both fail, throw FallbackExhaustedError. One line. No manual try-catch chains.

Session persistence via autoRestore stores the last-connected wallet name in localStorage and attempts to reconnect on page load. Users stay connected across refreshes without re-approving the extension every time.

Middleware for observability

manager.use(async (ctx, next) => {
  console.log(`Starting ${ctx.operation} on ${ctx.adapterName}`);
  await next();
  if (ctx.error) {
    analytics.track('wallet_error', { 
      operation: ctx.operation, 
      error: ctx.error.message 
    });
  }
});
Enter fullscreen mode Exit fullscreen mode

Every wallet operation; connect, disconnect, signIntent, signMessage - passes through the middleware chain. The context object gives you the operation type, adapter name, intent payload, result, and any error. Useful for logging, analytics, and debugging production issues without adding instrumentation to every component.

Typed Errors: No More catch (e: any)

Every error from the kit is a typed class inheriting from MidnightWalletError. You can branch on error type rather than parsing message strings:

import { 
  ProviderNotFoundError,
  ConnectionRejectedError,
  FallbackExhaustedError,
  NetworkMismatchError 
} from 'midnight-wallet-kit';

try {
  await connect('Lace');
} catch (e) {
  if (e instanceof ProviderNotFoundError) {
    showInstallPrompt('lace');
  } else if (e instanceof ConnectionRejectedError) {
    showRejectedMessage();
  } else if (e instanceof NetworkMismatchError) {
    showNetworkSwitchPrompt();
  }
}
Enter fullscreen mode Exit fullscreen mode

NetworkMismatchError in particular is one you'll hit in the wild, if a user switches their wallet to mainnet while your dApp is pointed at preprod, the session breaks silently without this check. The kit detects and surfaces it explicitly.

Testing Without a Browser Extension

This is the part that usually breaks dApp test suites. Testing wallet integration normally requires a real browser extension to be installed and configured, which makes CI impossible.

import { MockWalletAdapter } from 'midnight-wallet-kit/testing';

const adapter = new MockWalletAdapter({
  name: 'TestWallet',
  address: 'mn_addr1_test...',
  coinPublicKey: 'test_cpk',
  signatureOverride: '0xmocksignature',
  signDelay: 100,        // simulate realistic latency
  shouldRejectSign: false
});

manager.register(adapter);
Enter fullscreen mode Exit fullscreen mode

You can simulate connection failures, signing rejections, latency, and specific return values. No browser, no extension, no environment setup; just a mock that implements the same MidnightWallet interface as the real adapters.

// Test the rejection flow
const failAdapter = new MockWalletAdapter({
  name: 'RejectingWallet',
  shouldRejectConnect: true
});

await expect(manager.connect('RejectingWallet'))
  .rejects.toThrow(ConnectionRejectedError);
Enter fullscreen mode Exit fullscreen mode

SSR and Next.js

While building Midnight Club in Next.js, window.lace and window.midnight were being accessed during server-side rendering, causing window is not defined crashes that were silent in development but broke production builds.

The React hooks are SSR-safe. Provider detection (window.lace, window.midnight) only runs on the client; no window is not defined errors during Next.js server-side rendering. WalletProvider guards all DOM access behind a mounted check, so your app hydrates cleanly without injecting wallet state during SSR.

What's Next

The kit currently supports Lace and 1AM via InjectedWalletAdapter. Hardware wallet support is the natural next step as the Midnight ecosystem matures and physical signing devices become available.

If you're building on Midnight and hit something the kit doesn't handle, an edge case in the provider, a new wallet, a signing flow that doesn't fit the current API - issues and PRs are open at github.com/tusharpamnani/midnight-wallet-kit

Top comments (1)

Collapse
 
alex_pestchanker profile image
Alex Pestchanker Midnight Aliit Fellowship

Great work Tushar! these type of SDKs are key to foster a mature ecosystem.