Understanding Wallet Sync in the Midnight SDK
The first time I called balanceUnboundTransaction in a freshly-initialized wallet, I got back a transaction with wildly wrong token balances. No error. No warning. The function ran fine — it just silently used stale state and produced garbage.
That was my introduction to wallet sync on Midnight, and it's the thing that burns almost every developer building with the SDK. The sync model is genuinely different from what you're used to, and the failure mode is silent enough that you can ship bugs to production without realizing anything is wrong.
This guide covers how Midnight's three sub-wallets relate to each other, why you must wait for sync before calling balance-sensitive APIs, and the TypeScript patterns that make sync reliable in real applications.
Three Wallets in One
When you build a Midnight wallet with WalletBuilder.build(), you're not getting one wallet — you're getting three, each with its own ledger view:
The shielded wallet holds private NIGHT tokens. Balances here are kept confidential through ZK proofs. When you send a private transfer, the shielded wallet is involved.
The unshielded wallet holds public NIGHT tokens. These are visible on-chain. Moving tokens between shielded and unshielded wallets requires a shielding or unshielding operation.
The DUST wallet holds DUST tokens, which are used to pay transaction fees. DUST is always public — there's no private DUST. Every transaction on Midnight consumes DUST regardless of whether the main transfer involves NIGHT or not.
These three views sync independently against the indexer. After WalletBuilder.build() returns, none of them are synchronized yet. You've got a wallet instance, but it has no knowledge of the chain state.
import { WalletBuilder } from '@midnight-ntwrk/wallet';
import { NetworkId } from '@midnight-ntwrk/zswap';
const wallet = await WalletBuilder.build(
process.env.INDEXER_HTTP_URI!,
process.env.INDEXER_WS_URI!,
process.env.PROVER_SERVER_URI!,
process.env.SUBSTRATE_NODE_URI!,
process.env.WALLET_SEED_PHRASE!,
NetworkId.TestNet,
'info',
);
wallet.start();
// At this point, wallet.state() is not synced yet.
// Any balance-sensitive call here will produce incorrect results.
The call to wallet.start() kicks off the sync process in the background. But start() returns immediately — it doesn't wait for sync to complete.
Why balanceUnboundTransaction Fails Before Sync
balanceUnboundTransaction takes a transaction and figures out the token movements needed to fund it. To do that, it needs to know your current wallet state: what UTXOs you have, what their values are, which have already been spent.
Before sync, the wallet doesn't have this information. It either returns a transaction that assumes empty balances, or it throws, depending on the SDK version. Either way, the result is wrong.
The error pattern that catches people looks like this:
// This looks safe but isn't
wallet.start();
const tx = await contract.callTx.someContractMethod(args);
// tx was assembled with unsynced state — silently incorrect
The fix isn't to add a sleep or a retry. The fix is to wait for sync before touching any balance-sensitive API.
Waiting for Sync: The Right Pattern
The wallet exposes its state as an RxJS observable via wallet.state(). The state object includes an isSynced flag that becomes true once the initial sync is complete.
Here's the pattern that actually works:
import { filter, firstValueFrom } from 'rxjs';
async function waitForSync(wallet: Wallet): Promise<void> {
await firstValueFrom(
wallet.state().pipe(
filter((state) => state.isSynced)
)
);
}
firstValueFrom resolves with the first value emitted by the observable that passes the filter. Once isSynced is true, the promise resolves. You can then safely call balance-sensitive APIs.
A complete startup sequence looks like this:
const wallet = await WalletBuilder.build(/* ... */);
wallet.start();
console.log('Waiting for wallet sync...');
await waitForSync(wallet);
console.log('Wallet synced. Ready to transact.');
// Now safe to call balance-sensitive APIs
const balanceInfo = await wallet.balances();
Put waitForSync wherever you initialize your wallet, before any code that touches balances, transaction building, or DUST queries. If you're building a server, call it once at startup and don't let request handlers through until it resolves.
The DUST Wallet isStrictlyComplete() Bug
There's a specific gotcha with the DUST sub-wallet. When you check sync state through isSynced, it covers the shielded and unshielded wallets. But the DUST wallet has its own separate completeness check: isStrictlyComplete().
The problem: isSynced can be true while isStrictlyComplete() on the DUST wallet is still false. In that window, DUST balance queries return incorrect results. If you're building a transaction that involves fee payment — which is every transaction — you can end up with a transaction that's missing the right DUST input.
The state object gives you access to this:
async function waitForFullSync(wallet: Wallet): Promise<void> {
await firstValueFrom(
wallet.state().pipe(
filter((state) => {
if (!state.isSynced) return false;
// Check DUST wallet is also fully synced
if (!state.dustState?.isStrictlyComplete()) return false;
return true;
})
)
);
}
The dustState field contains the DUST wallet's sync state. isStrictlyComplete() returns true only when the DUST wallet has processed all relevant indexer events and has an accurate view of DUST balances.
Whether you need to check isStrictlyComplete() depends on your use case. If you're building read-only queries that don't touch DUST at all, the basic isSynced check is enough. If you're building transactions — which is most things — you want the full check.
A Robust Sync Wrapper
Here's a more complete wrapper that handles both sync conditions, adds a configurable timeout, and gives you diagnostic output:
import { filter, firstValueFrom, timeout, TimeoutError } from 'rxjs';
import type { Wallet, WalletState } from '@midnight-ntwrk/wallet';
interface SyncOptions {
timeoutMs?: number;
requireDustSync?: boolean;
onProgress?: (state: WalletState) => void;
}
async function waitForWalletSync(
wallet: Wallet,
options: SyncOptions = {}
): Promise<void> {
const {
timeoutMs = 60_000,
requireDustSync = true,
onProgress,
} = options;
const syncedState = await firstValueFrom(
wallet.state().pipe(
filter((state) => {
if (onProgress) onProgress(state);
if (!state.isSynced) return false;
if (requireDustSync && state.dustState) {
if (!state.dustState.isStrictlyComplete()) return false;
}
return true;
}),
timeout(timeoutMs)
)
).catch((err) => {
if (err instanceof TimeoutError) {
throw new Error(
`Wallet sync timed out after ${timeoutMs}ms. ` +
`The indexer may be unavailable or the wallet is waiting for a block.`
);
}
throw err;
});
}
Usage in a real application:
const wallet = await WalletBuilder.build(/* ... */);
wallet.start();
await waitForWalletSync(wallet, {
timeoutMs: 120_000, // 2 minutes for cold start on testnet
requireDustSync: true,
onProgress: (state) => {
if (state.syncProgress !== undefined) {
console.log(`Sync progress: ${state.syncProgress}%`);
}
},
});
The timeout prevents your application from hanging indefinitely if the indexer goes offline or the network is unreachable. Two minutes is a reasonable upper bound for testnet cold starts; on mainnet with a fully-synced indexer you should see sync complete in under 30 seconds.
Debugging Stuck Syncs
When sync doesn't complete and your timeout fires, you need to figure out why. The most common causes:
The indexer isn't reachable. Check that INDEXER_HTTP_URI and INDEXER_WS_URI are correct. The HTTP URL is used for initial state download; the WebSocket is used for live updates. Both need to be reachable.
// Quick reachability check before building the wallet
const response = await fetch(`${process.env.INDEXER_HTTP_URI}/health`);
if (!response.ok) {
throw new Error('Indexer is not reachable');
}
The indexer is behind the node. Midnight indexers don't sync in real time. If you just started a local stack, the indexer might be processing block history. Check indexer logs for progress.
The wallet seed has no transaction history. A fresh wallet with no transactions still syncs, but it syncs faster because there's nothing to process. If sync takes more than 30 seconds for a fresh wallet, the indexer connection is likely the issue.
The observable is never subscribed. If you use wallet.state() but don't subscribe (or use the wrong RxJS operator), the filter never runs. Make sure you're using firstValueFrom or a subscriber that actually consumes the stream.
You can add diagnostic logging to understand what state the wallet is in:
const subscription = wallet.state().subscribe((state) => {
console.log({
isSynced: state.isSynced,
dustSynced: state.dustState?.isStrictlyComplete() ?? 'n/a',
blockHeight: state.syncedBlockHeight,
});
});
// Unsubscribe when done
subscription.unsubscribe();
syncedBlockHeight tells you the most recent block the wallet has processed. Compare it against the current chain tip to understand how far behind the wallet is.
Putting It Together: A Full Startup Sequence
Here's a complete, production-ready startup sequence that handles sync correctly:
import { WalletBuilder } from '@midnight-ntwrk/wallet';
import { NetworkId } from '@midnight-ntwrk/zswap';
import { filter, firstValueFrom, timeout, TimeoutError } from 'rxjs';
async function startWallet() {
const wallet = await WalletBuilder.build(
process.env.INDEXER_HTTP_URI!,
process.env.INDEXER_WS_URI!,
process.env.PROVER_SERVER_URI!,
process.env.SUBSTRATE_NODE_URI!,
process.env.WALLET_SEED_PHRASE!,
NetworkId.TestNet,
'warn', // reduce log noise in production
true, // discard tx history for stateless servers
);
wallet.start();
try {
await firstValueFrom(
wallet.state().pipe(
filter((state) => {
return state.isSynced && (state.dustState?.isStrictlyComplete() ?? true);
}),
timeout(120_000)
)
);
} catch (err) {
if (err instanceof TimeoutError) {
await wallet.close();
throw new Error('Wallet failed to sync within 120 seconds');
}
throw err;
}
return wallet;
}
Use the wallet returned from startWallet() as a singleton. Don't rebuild it on each request, and don't call any balance-sensitive API before this function resolves.
Summary
Midnight wallet sync works in the background after you call wallet.start(). Until sync completes, balanceUnboundTransaction and related APIs return incorrect results. The right pattern is to wait on wallet.state().pipe(filter(s => s.isSynced)) before any balance-sensitive code.
For full correctness when paying fees, also wait for the DUST wallet's isStrictlyComplete() to return true. Use a timeout to prevent hanging on indexer unavailability, and add diagnostic logging when you're chasing sync issues.
Once your startup sequence correctly awaits sync, the rest of your application code can treat the wallet as ready — no per-call sync checks needed.
Top comments (0)