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
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
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' }));
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>
);
}
That's the entire integration. Everything below this point is application code.
The Four Hooks
useWallet(): connection state
const { address, isConnected, connectionState, error } = useWallet();
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)
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
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
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 fullsignIntent()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']);
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
});
}
});
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();
}
}
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);
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);
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)
Great work Tushar! these type of SDKs are key to foster a mature ecosystem.