📁 Full Source Code: midnight-dapp-connect
Target audience: Developers
Summary
This guide walks through the complete lifecycle of connecting web apps to the Midnight blockchain. You learn how to detect injected wallets in the browser, make a connection, monitor state changes, and submit transactions through both the browser extension flow and the CLI. You also learn the difference between them.
Project setup
Start with the standalone repository:
git clone https://github.com/0xfdbu/midnight-dapp-connect.git
cd midnight-dapp-connect
npm install
The finished project structure looks like this:
midnight-dapp-connect/
├── scripts/
│ ├── test-subscription.ts # Wallet-state subscription test
│ └── test-v3-sync-and-transfer.ts # CLI transfer test
├── src/
│ ├── components/ # React components
│ ├── hooks/ # Wallet connection and state hooks
│ ├── lib/ # CLI transaction helpers
│ └── pages/ # React pages (Home, Transfer)
├── package.json
└── tutorial.md # This guide
Run the frontend with npm run dev.
Prerequisites
- Node.js installed (v20+)
- A Midnight wallet (for example, 1AM or Lace)
- Some Preprod faucet NIGHT tokens
Dependencies
The project builds on the Midnight.js SDK and related libraries. These packages handle the heavy lifting:
| Package | Purpose |
|---|---|
@midnight-ntwrk/dapp-connector-api |
Wallet connector API |
@midnight-ntwrk/ledger-v8 |
Transaction serialization |
@midnight-ntwrk/midnight-js-utils |
Shared utilities |
@midnight-ntwrk/midnight-js-fetch-zk-config-provider |
ZK config fetching |
@midnight-ntwrk/midnight-js-network-id |
Network identification helpers |
@midnight-ntwrk/wallet-sdk-address-format |
Address encoding/decoding (Bech32) |
@midnight-ntwrk/wallet-sdk-facade |
Wallet facade SDK |
@midnight-ntwrk/wallet-sdk-shielded |
Shielded wallet SDK |
@midnight-ntwrk/wallet-sdk-unshielded-wallet |
Unshielded wallet SDK |
@midnight-ntwrk/wallet-sdk-dust-wallet |
Dust wallet SDK |
@midnight-ntwrk/wallet-sdk-hd |
HD wallet key derivation |
@scure/bip39 |
BIP-39 mnemonic handling |
react, react-dom, react-router-dom
|
Frontend framework |
zustand |
State management |
rxjs |
Reactive extensions for subscriptions |
semver |
Semantic version parsing |
ws |
WebSocket client |
typescript |
TypeScript compiler |
vite |
Build tool |
Run npm install to install them automatically. See package.json for the full list.
Architecture: browser vs CLI
Midnight DApps operate in two different security contexts. Understanding the boundary between them is essential before writing any code.
| Context | Custodian | Balancing method | Signature | Use case |
|---|---|---|---|---|
| Browser / DApp | Injected extension | balanceUnsealedTransaction |
Wallet handles it | UI / DApps |
| CLI / backend | Your script |
transferTransaction + signRecipe
|
Manual | Agents, automation |
In the browser flow, the wallet extension handles the user's private key (typically encrypted on the user's device with a password). All keys are derived internally, and the DApp never sees secret material. The DApp builds a transaction blueprint, serialises it, and hands it to the wallet through the DApp Connector API. The wallet selects inputs, adds balancing outputs through balanceUnsealedTransaction, creates the signatures, and returns a finalised transaction.
In the CLI / backend flow, your script holds the 24-word mnemonic directly. It derives ZswapSecretKeys, DustSecretKey, and an UnshieldedKeystore from the mnemonic. Because there is no wallet extension to handle balancing and signing, the script uses transferTransaction to build a recipe, then signRecipe with the unshielded keystore, then finalizeRecipe and submitTransaction. The script acts as the wallet.
Both flows submit the same transaction format to the Midnight network. The only difference is who holds the keys and who performs the balancing.
Detecting wallets via window.midnight
Midnight wallets inject a global window.midnight object before page load.
Note: COMPATIBLE_CONNECTOR_API_VERSION is '4.x', not '^4.0.0'. The '4.x' semver range accepts any 4.x.y version the wallet reports.
View the full wallet.constants.ts and useWallet.ts files on GitHub.
// src/hooks/wallet.constants.ts
export const COMPATIBLE_CONNECTOR_API_VERSION = '4.x';
export const NETWORK_ID = 'preprod';
The detection function enumerates window.midnight, validates each entry, and filters by version.
// src/hooks/useWallet.ts
export function getCompatibleWallets(): InitialAPI[] {
if (!window.midnight) return [];
return Object.values(window.midnight).filter(
(wallet): wallet is InitialAPI =>
!!wallet &&
typeof wallet === 'object' &&
'apiVersion' in wallet &&
semver.satisfies(wallet.apiVersion, COMPATIBLE_CONNECTOR_API_VERSION)
);
}
Wallet selection modal
When one or more wallets are installed, a modal is shown so the user can pick.
View the full WalletSelectModal.tsx file on GitHub.
// src/components/WalletSelectModal.tsx
function getWalletIcon(rdns: string | undefined): string | null {
if (!rdns) return null;
if (rdns.includes('lace')) return laceSvg;
if (rdns.includes('1am') || rdns.includes('iam')) return iamSvg;
return null;
}
export function WalletSelectModal({ isOpen, onClose, wallets, onSelect, connecting }: Props) {
const [pending, setPending] = useState<InitialAPI | null>(null);
if (!isOpen) return null;
return (
<div>
<h3>Connect Wallet</h3>
{wallets.map((w) => (
<button
key={w.rdns}
onClick={() => {
setPending(w);
onSelect(w);
}}
disabled={connecting}
>
<img src={getWalletIcon(w.rdns) ?? fallback} />
<span>{w.name}</span>
</button>
))}
{connecting && pending && <div>Connecting to {pending.name}...</div>}
<Button onClick={onClose} disabled={connecting}>Cancel</Button>
</div>
);
}
Installed wallets are discovered using InitialAPI[]. Each object is injected by a browser-installed wallet extension.
Connecting to Lace or 1AM
ConnectButton ties detection, selection, and connection together. If a single wallet is detected, it directly prompts for wallet connection approval. If multiple wallets are detected, a modal is shown.
View the full ConnectButton.tsx file on GitHub.
// src/components/ConnectButton.tsx
export function ConnectButton() {
const { isConnected, connect, setWallet, setShowAccountModal } = useWalletStore();
const [wallets] = useState(() => getCompatibleWallets());
const [showModal, setShowModal] = useState(false);
const handleConnect = async (selectedWallet: InitialAPI) => {
setWallet(selectedWallet);
setShowModal(false);
await connect('preprod');
};
const handleClick = () => {
if (isConnected) {
setShowAccountModal(true);
} else if (wallets.length === 1) {
handleConnect(wallets[0]);
} else {
setShowModal(true);
}
};
return (
<>
<Button onClick={handleClick}>Connect Wallet</Button>
<Modal isOpen={showModal} onClose={() => setShowModal(false)}>
<WalletSelectModal
isOpen={showModal}
onClose={() => setShowModal(false)}
wallets={wallets}
onSelect={handleConnect}
connecting={isConnecting}
/>
</Modal>
</>
);
}
The connection flow
When wallet.connect(networkId) is called, it triggers the wallet extension connection flow.
View the full useWallet.ts file on GitHub.
// src/hooks/useWallet.ts
connect: async (networkId = NETWORK_ID) => {
const { wallet } = get();
if (!wallet) {
set({ error: 'No wallet selected' });
return;
}
set({ isConnecting: true, error: null });
try {
const connectedApi = await wallet.connect(networkId);
const status = await connectedApi.getConnectionStatus();
if (status.status !== 'connected') {
throw new Error(`Wallet status: ${status.status}`);
}
const config = await connectedApi.getConfiguration();
const shielded = await connectedApi.getShieldedAddresses();
const unshielded = await connectedApi.getUnshieldedAddress();
const dustAddr = await connectedApi.getDustAddress();
set({
connectedApi,
isConnected: true,
config,
addresses: {
shieldedAddress: shielded.shieldedAddress,
shieldedCoinPublicKey: shielded.shieldedCoinPublicKey,
shieldedEncryptionPublicKey: shielded.shieldedEncryptionPublicKey,
unshieldedAddress: unshielded.unshieldedAddress,
dustAddress: dustAddr.dustAddress,
},
balances: {
shielded: {},
unshielded: {},
dust: { balance: 0n, cap: 0n },
},
});
localStorage.setItem('midnight_last_wallet', wallet.rdns);
} catch (err) {
set({
error: err instanceof Error ? err.message : 'Connection failed',
isConnected: false,
connectedApi: null,
});
} finally {
set({ isConnecting: false });
}
},
Note: connect() fetches addresses, not balances. The dustAddress is fetched here, but balances are loaded separately in loadWalletState().
Auto-reconnect
Store the last connected wallet's rdns in localStorage and attempt to reconnect on page reload.
// src/hooks/useWallet.ts
export async function tryAutoConnect(): Promise<void> {
const lastRdns = localStorage.getItem('midnight_last_wallet');
if (!lastRdns || !window.midnight) return;
const wallets = getCompatibleWallets();
const match = wallets.find((w) => w.rdns === lastRdns);
if (!match) return;
const store = useWalletStore.getState();
store.setWallet(match);
await store.connect();
}
Account modal
Clicking the connected button opens a popup showing balances, addresses, copy buttons, refresh, and disconnect.
View the full AccountModal.tsx file on GitHub.
// src/components/AccountModal.tsx
export function AccountModal() {
const {
showAccountModal, setShowAccountModal,
addresses, balances, config,
isLoadingState, loadWalletState,
disconnect, wallet, error,
} = useWalletStore();
const [copiedKey, setCopiedKey] = useState<string | null>(null);
const handleCopy = (key: string, address: string | undefined) => {
if (!address) return;
navigator.clipboard.writeText(address);
setCopiedKey(key);
setTimeout(() => setCopiedKey(null), 2000);
};
// Renders shielded/unshielded/dust balances,
// copyable addresses, refresh button, disconnect
}
Subscribing to wallet state changes
The DApp Connector v4 API does not expose a native push/subscription API. Reactive updates are built on top of polling.
View the full useWalletSubscription.ts file on GitHub.
useWalletSubscription hook calls loadWalletState() every 15 seconds
// src/hooks/useWalletSubscription.ts
export function useWalletSubscription(options = {}) {
const { balanceInterval = 15000, connectionInterval = 5000 } = options;
const { connectedApi, isConnected, loadWalletState, disconnect } = useWalletStore();
const lastStatusRef = useRef<'connected' | 'disconnected'>('disconnected');
// 1. Balance polling
useEffect(() => {
if (!isConnected || !connectedApi) return;
loadWalletState();
const id = setInterval(() => loadWalletState(), balanceInterval);
return () => clearInterval(id);
}, [isConnected, connectedApi, loadWalletState, balanceInterval]);
// 2. Connection-status polling
useEffect(() => {
if (!isConnected || !connectedApi) return;
const check = async () => {
try {
const status = await connectedApi.getConnectionStatus();
lastStatusRef.current = status.status;
if (status.status === 'disconnected') disconnect();
} catch {
if (lastStatusRef.current === 'connected') disconnect();
}
};
const id = setInterval(check, connectionInterval);
return () => clearInterval(id);
}, [isConnected, connectedApi, disconnect, connectionInterval]);
}
loadWalletState fetches all balance types at the same time.
// src/hooks/useWallet.ts
loadWalletState: async () => {
const { connectedApi } = get();
if (!connectedApi) return;
set({ isLoadingState: true, error: null });
try {
const [shieldedBalances, unshieldedBalances, dustBalance] = await Promise.all([
connectedApi.getShieldedBalances(),
connectedApi.getUnshieldedBalances(),
connectedApi.getDustBalance(),
]);
set({
balances: {
shielded: shieldedBalances,
unshielded: unshieldedBalances,
dust: dustBalance,
},
});
} catch (err) {
set({ error: err instanceof Error ? err.message : 'Failed to load wallet state' });
} finally {
set({ isLoadingState: false });
}
},
CLI: native push subscriptions
Using the Wallet SDK, you get true push-based state through RxJS.
Build a small helper function. View the full transaction-cli.ts file on GitHub.
// src/lib/transaction-cli.ts
import * as Rx from 'rxjs';
export function subscribeToWalletSdkState(
ctx: CliWalletContext,
listener: (state: any) => void
): () => void {
const sub = (ctx.wallet as any).state().subscribe(listener);
return () => sub.unsubscribe();
}
export async function waitForWalletSync(ctx: CliWalletContext): Promise<any> {
return Rx.firstValueFrom(
(ctx.wallet as any)
.state()
.pipe(Rx.filter((s: any) => s.isSynced))
);
}
Build the flow. View test-subscription.ts for the complete script.
// scripts/test-subscription.ts
const ctx = await restoreWalletState(MNEMONIC);
// 1. Block until fully synced
await waitForWalletSync(ctx);
// 2. Subscribe to push updates
const unsubscribe = subscribeToWalletSdkState(ctx, (state: any) => {
if (!state.isSynced) return;
const shielded = state.shielded?.balances ?? {};
const unshielded = state.unshielded?.balances ?? {};
const dust = state.dust?.balance(new Date()) ?? 0n;
console.log('Shielded:', Object.entries(shielded)
.map(([k, v]) => `${k.slice(0, 8)}..=${v?.toString()}`).join(', ') || '(empty)');
console.log('Unshielded:', Object.entries(unshielded)
.map(([k, v]) => `${k.slice(0, 8)}..=${v?.toString()}`).join(', ') || '(empty)');
console.log('Dust:', dust.toString());
});
The browser transaction flow (balanceUnsealedTransaction)
Once connected, the browser DApp requests the wallet to balance and submit the transaction. The app uses manual construction: building an Intent with an UnshieldedOffer, proving it, then calling balanceUnsealedTransaction.
The DApp Connector API also exposes makeTransfer, a convenience method for simple transfers. This app does not use it because the manual path gives full control over the transaction blueprint and works for both pure transfers and smart contract calls.
Here is the full lifecycle of the transfer page. The recipient field starts empty; a Use my address (self-transfer) button prefills it with the connected wallet's unshielded address so you can safely test with a self-send. View the full Transfer.tsx file on GitHub.
// src/pages/Transfer.tsx
const handleTransfer = useCallback(async () => {
if (!connectedApi) {
setError('Wallet not connected');
return;
}
try {
const value = BigInt(Math.round(Number(amount) * 1_000_000));
// 1. Decode Bech32 address to raw hex bytes
const parsed = MidnightBech32m.parse(recipient);
const unshieldedAddr = parsed.decode(UnshieldedAddress, 'preprod');
const hexRecipient = unshieldedAddr.data.toString('hex');
// 2. Build an unproven transaction blueprint manually
const unshieldedOffer = UnshieldedOffer.new(
[], // inputs — wallet selects these
[{ value, owner: hexRecipient, type: nativeToken().raw }],
[] // signatures — wallet adds these
);
const intent = Intent.new(new Date(Date.now() + 30 * 60 * 1000));
intent.guaranteedUnshieldedOffer = unshieldedOffer;
const unsealedTx = Transaction.fromParts('preprod', undefined, undefined, intent);
// 3. Prove the transaction (PreProof → Proof)
const zkConfigProvider = new FetchZkConfigProvider(window.location.origin);
const provingProvider = await connectedApi.getProvingProvider(zkConfigProvider);
const provenTx = await unsealedTx.prove(provingProvider, CostModel.initialCostModel());
const serializedTx = toHex(provenTx.serialize());
// 4. Wallet balances, signs, and pays fees
const result = await connectedApi.balanceUnsealedTransaction(serializedTx, { payFees: true });
// 5. Submit
await connectedApi.submitTransaction(result.tx);
setTxId(result.tx.slice(0, 64));
loadWalletState();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}, [connectedApi, recipient, amount, loadWalletState]);
Why each step matters
guaranteedUnshieldedOffer: A plain unshielded transfer belongs in the guaranteed segment of the intent (segment 0). The fallibleUnshieldedOffer segment is for offers that may fail alongside a guaranteed section. Using guaranteedUnshieldedOffer matches the reference implementations and removes the need for an as any cast.
Bech32 → hex: The DApp Connector returns addresses in Bech32 (mn_addr_preprod1...), but UnshieldedOffer.new expects raw hex bytes for the owner field.
The correct flow is:
const parsed = MidnightBech32m.parse(recipient);
const unshieldedAddr = parsed.decode(UnshieldedAddress, 'preprod');
const hexRecipient = unshieldedAddr.data.toString('hex');
Network ID: Transaction.fromParts must use 'preprod' (matching the wallet connection). Using 'undeployed' causes:
BALANCE_FAILED: invalid network ID - expect 'preprod' found 'undeployed'
tx.prove() and "unsealed": balanceUnsealedTransaction expects a transaction with the Proof marker. An unsealed transaction is already proven (tx.prove()) but has not yet been cryptographically bound. The wallet applies that binding while it balances the transaction and pays fees. Without prove(), the transaction serialises with proof-preimage (PreProof state) and the wallet rejects it with:
expected header tag '...proof...', got '...proof-preimage...'
Transaction identifier display: result.tx is the serialised balanced transaction, not its hash. The code displays result.tx.slice(0, 64) — the first 64 hex characters of that serialised transaction — as a concise success indicator. If you need the real transaction hash, compute it from the submitted transaction bytes.
Security model: In the browser flow, the DApp never sees secret keys. The wallet extension derives all keys locally and signs intents internally. The DApp only handles public addresses and serialised transaction bytes.
The CLI transaction flow
The CLI performs transactions without a browser wallet, which is essential for agents and other systems to act autonomously. While the browser flow delegates balancing and signing to the wallet extension via balanceUnsealedTransaction, the CLI flow holds the 24-word mnemonic and uses transferTransaction + signRecipe:
// scripts/test-v3-sync-and-transfer.ts
import { unshieldedToken } from '@midnight-ntwrk/ledger-v8';
const recipe = await ctx.wallet.transferTransaction(
[
{
type: 'unshielded',
outputs: [
{
amount: 1n,
receiverAddress: ctx.unshieldedKeystore.getBech32Address(),
type: unshieldedToken().raw,
},
],
},
],
{ shieldedSecretKeys: ctx.shieldedSecretKeys, dustSecretKey: ctx.dustSecretKey },
{ ttl: new Date(Date.now() + 30 * 60 * 1000) }
);
const signedRecipe = await ctx.wallet.signRecipe(
recipe,
(payload: Uint8Array) => ctx.unshieldedKeystore.signData(payload)
);
const finalized = await ctx.wallet.finalizeRecipe(signedRecipe);
const txId = await ctx.wallet.submitTransaction(finalized);
The ctx object is built from a 24-word mnemonic via restoreWalletState(); the full key derivation, WalletFacade initialisation, dust sync handling, and state persistence live in src/lib/transaction-cli.ts and scripts/test-v3-sync-and-transfer.ts. Run the script with:
MNEMONIC="word1 word2 ... word24" npx tsx scripts/test-v3-sync-and-transfer.ts
Note: Sometimes the transfer fails. This is often caused by network issues.
Conclusion
dapp-connect is a reference implementation for connecting to the Midnight blockchain from both the browser and the CLI. It demonstrates the complete wallet lifecycle, from detection to connection, state monitoring, and transaction construction, proving, balancing, signing, and submitting, across two different security contexts.
Next steps
- Clone the project at
https://github.com/0xfdbu/midnight-dapp-connect - Deploy a compact smart contract and integrate it into the DApp
- Build an AI agent around the CLI
Troubleshooting
Browser errors
| Error | Cause | Fix |
|---|---|---|
Invalid character 'm' at position 0 |
Bech32 address passed to UnshieldedOffer.new
|
Decode with MidnightBech32m.parse(addr).decode(UnshieldedAddress, 'preprod').data.toString('hex')
|
expected header tag '...proof...', got '...proof-preimage...' |
Missing tx.prove() before balanceUnsealedTransaction
|
Call await tx.prove(provingProvider, CostModel.initialCostModel())
|
BALANCE_FAILED: invalid network ID |
Wrong network in Transaction.fromParts
|
Use 'preprod', not 'undeployed'
|
No compatible wallet found |
Extension reports API version outside '4.x'
|
Update the wallet extension |
CLI errors
| Error | Cause | Fix |
|---|---|---|
Missing required configuration: 'provingServerUrl' |
WalletFacade.init missing proof server URL |
Add provingServerUrl: new URL('http://localhost:6300') to config |
Custom error: 192 |
Missing signRecipe step before finalizeRecipe
|
Add await wallet.signRecipe(recipe, signFn) before finalizeRecipe
|
Custom error: 170 |
Wallet not fully synced | Wait for isSynced = true before submitting |
| Dust sync timeout | First-time sync from genesis is slow | Use restoreWalletState(); save on SIGINT; allow 2h timeout |
Built with @midnight-ntwrk/midnight-js 4.0.4 and Wallet SDK 3.0.0 for the Midnight Preprod network.





Top comments (0)