You've set up the Midnight toolchain, written your first Compact contract, and you're ready to deploy. You call balanceUnboundTransaction and either nothing happens, or you get an error that gives you nothing useful to work with. You restart, try again, same result.
This is a sync timing problem, and it's one of the most common stumbling blocks for developers new to Midnight. Once you understand why it happens and how the wallet is structured internally, you won't fall into this trap again.
This tutorial covers what balanceUnboundTransaction needs before it can work, how Midnight's wallet splits into three separate sub-wallets that each sync independently, a real documented bug with the dust wallet on idle chains, and the safe pattern for waiting on sync before you touch any transaction logic.
What balanceUnboundTransaction actually does
When you build a transaction in Midnight, you start with an unbound transaction a description of what you intend to do without any inputs or outputs selected yet. Before the network can process it, the wallet needs to figure out which UTXOs to draw from, handle any shielded balancing using ZK notes, and attach tDUST to cover fees.
balanceUnboundTransaction does all of that in up to four steps: shielded balancing (creating a separate shielded balancing transaction), unshielded balancing (applied in place on the base transaction), dust/fee balancing (using tDUST tokens), and finally merging the shielded and fee components together.
The method signature reflects this it needs more than just the transaction:
const recipe = await facade.balanceUnboundTransaction(
tx,
{
shieldedSecretKeys: myZswapSecretKeys,
dustSecretKey: myDustSecretKey,
},
{ ttl: new Date(Date.now() + 60 * 60 * 1000) }, // 1 hour TTL
);
// recipe is an UnboundTransactionRecipe finalize it before submitting
const finalizedTx = await facade.finalizeRecipe(recipe);
The result is an UnboundTransactionRecipe, not a ready-to-submit transaction. You call finalizeRecipe on it afterward to get a FinalizedTransaction.
Both steps balanceUnboundTransaction and finalizeRecipe pull from the wallet's local state. The wallet doesn't query the network each time; it works from what it has already indexed. If the wallet hasn't finished syncing, that local state is incomplete. You might get an error, or worse, a "balanced" transaction built on stale data that the network rejects downstream with a cryptic error that looks like a contract issue rather than a sync issue.
The fix is simple in principle: wait for the wallet to finish syncing before you call any transaction methods. Understanding what that sync actually involves is what makes the fix reliable.
The three sub-wallets: shielded, unshielded, and dust
Midnight's wallet isn't a single component. It's a facade that wraps three separate sub-wallets, each responsible for a different type of asset. The FacadeState you get from facade.state() reflects all three:
// FacadeState structure
class FacadeState {
shielded: ShieldedWalletState; // state.shielded.state.progress
unshielded: UnshieldedWalletState; // state.unshielded.progress
dust: DustWalletState; // state.dust.state.progress
get isSynced(): boolean {
return (
this.shielded.state.progress.isStrictlyComplete() &&
this.dust.state.progress.isStrictlyComplete() &&
this.unshielded.progress.isStrictlyComplete()
);
}
}
Each sub-wallet syncs on its own timeline. All three need to reach the current chain tip before isSynced returns true.
Shielded wallet
The shielded wallet manages private assets protected by zero-knowledge proofs. Every shielded transaction on-chain is encrypted only the holder of the correct spending keys can read it. Your wallet scans every block, attempts trial decryption of every shielded output, and builds an internal Merkle tree of your unspent notes.
This is the heaviest sync of the three. For each block, the wallet runs cryptographic work to check whether outputs belong to you. On a chain with significant history, the first sync can take several minutes. On a fresh local devnet with minimal block history, it's much faster. The shielded wallet is considered synced when it has processed all blocks up to the current chain tip and decrypted all relevant notes.
Unshielded wallet
The unshielded wallet handles transparent UTXO-based assets tokens and coins visible on-chain. Syncing works similarly to how a Bitcoin wallet catches up: it scans for UTXOs associated with your public address and tracks which have been spent.
Because it doesn't need to perform ZK trial decryption, it syncs faster than the shielded wallet. It still scans from your wallet's birthday block (the height at which the wallet was created), so on chains with high transaction volume it's not instant.
Dust wallet
The dust wallet manages tDUST the small token denomination used to pay transaction fees. Every transaction you submit consumes tDUST from this wallet. It aggregates small UTXOs and keeps enough available to fund transactions without requiring you to manage fees manually.
The dust wallet has a different sync model from the other two. Rather than scanning for your addresses or decrypting your notes, it tracks and aggregates a class of small UTXOs. This works efficiently on active chains, but it creates a specific edge case on idle ones that can silently break your application.
The dust wallet bug: isStrictlyComplete() on idle chains
On an active chain, there's a regular stream of new blocks. The dust wallet processes each block, updates its progress, and eventually reports that it has caught up to the current tip isStrictlyComplete() returns true.
On an idle chain one with no recent transactions that stream stalls. The dust wallet reaches the last known block and then waits. No new blocks arrive, so it never gets confirmation that it's at the tip. isStrictlyComplete() keeps returning false.
The result: any sync strategy that requires isStrictlyComplete() to return true for the dust wallet will silently wait forever on an idle chain.
This hits hardest in local development. Your local devnet sits idle between your own transactions. If you're running integration tests, the chain may have no activity at all between test runs. Here's the pattern from the Midnight bulletin board example (wallet-utils.ts) that demonstrates the problem:
Rx.filter(
(state: FacadeState) =>
isProgressStrictlyComplete(state.shielded.state.progress) &&
isProgressStrictlyComplete(state.dust.state.progress) && // ← never true on idle chains
isProgressStrictlyComplete(state.unshielded.progress),
)
The filter requires all three to return true before passing the state downstream. On an idle chain, state.dust.state.progress.isStrictlyComplete() never flips, so this filter never passes. The observable waits, your application appears frozen, and after the timeout (default 180 seconds) it finally throws an error.
If you've ever run a Midnight app locally and watched it hang at "syncing wallet..." without ever progressing, this is why.
The safe sync pattern
The right approach is to use facade.state() combined with a filter on isSynced and an explicit timeout:
import { WalletFacade } from '@midnight-ntwrk/wallet-sdk-facade';
import * as Rx from 'rxjs';
async function waitForWalletSync(facade: WalletFacade): Promise<void> {
await Rx.firstValueFrom(
facade.state().pipe(
Rx.filter(s => s.isSynced),
Rx.timeout({
each: 180_000,
with: () =>
Rx.throwError(
() => new Error('Wallet sync timed out. Check your node connection.'),
),
}),
),
);
}
Breaking this down:
-
facade.state()returns anObservable<FacadeState>built withcombineLatestacross all three sub-wallet state streams. It emits a newFacadeStatewhenever any sub-wallet updates. -
filter(s => s.isSynced)checks the facade'sisSyncedcomputed getter, which evaluatesisStrictlyComplete()on all three sub-wallets synchronously. States whereisSyncedisfalseare discarded. -
firstValueFromresolves the observable into a promise that completes after the first state whereisSyncedistrue. -
timeoutensures the promise rejects with a meaningful error if sync never completes which happens when the node is unreachable or, on idle chains, when the dust wallet's progress never reaches strictly complete.
The timeout is what turns a silent hang into an actionable error. Without it, on an idle chain where the dust wallet never completes, your application waits indefinitely.
A complete working example
Here's an initialization and deployment sequence. Wallet setup requires key derivation via HDWallet the focus here is the sync wait between start() and any transaction call:
import * as ledger from '@midnight-ntwrk/ledger-v8';
import { WalletFacade } from '@midnight-ntwrk/wallet-sdk-facade';
import { ShieldedWallet } from '@midnight-ntwrk/wallet-sdk-shielded';
import { UnshieldedWallet, PublicKey, createKeystore } from '@midnight-ntwrk/wallet-sdk-unshielded-wallet';
import { DustWallet } from '@midnight-ntwrk/wallet-sdk-dust-wallet';
import * as Rx from 'rxjs';
async function initializeWallet(
configuration: DefaultConfiguration,
shieldedSecretKeys: ledger.ZswapSecretKeys,
dustSecretKey: ledger.DustSecretKey,
unshieldedKeystore: ReturnType<typeof createKeystore>,
): Promise<WalletFacade> {
// WalletFacade.init() wires together the three sub-wallets and supporting services
const facade = await WalletFacade.init({
configuration,
shielded: (config) => ShieldedWallet(config).startWithSecretKeys(shieldedSecretKeys),
unshielded: (config) => UnshieldedWallet(config).startWithPublicKey(PublicKey.fromKeyStore(unshieldedKeystore)),
dust: (config) =>
DustWallet(config).startWithSecretKey(dustSecretKey, ledger.LedgerParameters.initialParameters().dust),
});
// start() begins sync for all three sub-wallets — it requires the secret keys needed
// for shielded and dust scanning, but it returns before sync completes
await facade.start(shieldedSecretKeys, dustSecretKey);
// Wait until all three sub-wallets report isSynced === true before proceeding
await Rx.firstValueFrom(
facade.state().pipe(
Rx.filter(s => s.isSynced),
Rx.timeout({
each: 180_000,
with: () =>
Rx.throwError(() => new Error('Wallet sync timed out after 3 minutes')),
}),
),
);
return facade;
}
async function deployContract(
facade: WalletFacade,
unboundTx: UnboundTransaction,
shieldedSecretKeys: ledger.ZswapSecretKeys,
dustSecretKey: ledger.DustSecretKey,
): Promise<void> {
const ttl = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
// Balance the transaction — selects UTXOs, handles shielded/unshielded/dust
const recipe = await facade.balanceUnboundTransaction(
unboundTx,
{ shieldedSecretKeys, dustSecretKey },
{ ttl },
);
// Finalize the recipe into a submittable transaction
const finalizedTx = await facade.finalizeRecipe(recipe);
await facade.submitTransaction(finalizedTx);
}
The unsafe version which you'll see in early examples looks like this:
// Unsafe: start() returns before sync is complete
await facade.start(shieldedSecretKeys, dustSecretKey);
const recipe = await facade.balanceUnboundTransaction(unboundTx, secretKeys, { ttl }); // race condition
facade.start() returns as soon as the sync process begins, not when it completes. If you call balanceUnboundTransaction immediately after, you're in a race against all three sub-wallets, and on any chain with real history, the wallet isn't ready yet.
Showing sync progress in a frontend
If you're building a UI, subscribe to the wallet state and surface progress to your users:
facade.state().subscribe(state => {
if (!state.isSynced) {
console.log('Syncing...');
// Access per-sub-wallet balances once synced
// state.shielded.balances — shielded token balances
// state.unshielded.balances — unshielded token balances
// state.dust.walletBalance(new Date()) — available tDUST
} else {
console.log('Wallet ready.');
}
});
A progress indicator while the wallet syncs prevents users from concluding the application is broken and closing it before it finishes.
Common mistakes to avoid
Calling balanceUnboundTransaction right after facade.start(). The start call returns early. Always wait on isSynced before any transaction work.
Not setting a sync timeout. On idle chains, the dust wallet may never reach strictly complete and isSynced will stay false. Add Rx.timeout() to convert a silent hang into a clear error.
Assuming the wallet stays synced on restart. If your user closes and reopens the application, the wallet needs to catch up on blocks produced while it was offline. Always wait on isSynced, even if the wallet has synced before.
Checking isStrictlyComplete() on individual sub-wallet progress directly. Use s.isSynced from the facade state instead. The facade getter is the right level of abstraction checking individual sub-wallets bypasses any facade-level handling and exposes you directly to the idle chain behavior.
Summary
The wallet sync issue on Midnight comes from one simple misunderstanding: facade.start() begins sync but doesn't wait for it to finish. All three sub-wallets shielded, unshielded, and dust need to reach the current chain tip before balanceUnboundTransaction has the complete local state it needs.
The dust wallet makes this harder. On chains with no recent activity, its progress never reaches isStrictlyComplete() === true, which means isSynced stays false and any code waiting on sync appears to hang.
The safe pattern pairs filter(s => s.isSynced) with a timeout() operator so your application either proceeds cleanly or fails with a useful error message:
await Rx.firstValueFrom(
facade.state().pipe(
Rx.filter(s => s.isSynced),
Rx.timeout({ each: 180_000, with: () => Rx.throwError(() => new Error('Sync timed out')) }),
),
);
Put this between facade.start() and any call to balanceUnboundTransaction, and your deploys will start on solid ground every time.
Top comments (0)