midnight-ops-doctor
Notes from running a Midnight Network backend in production. Look up your symptom in the triage table, follow the link to the runbook.
Triage
Match the user's words against the left column. Load only the doc on the right. Don't pre-load others.
| Dev says... | Load |
|---|---|
| "stuck at X%", "wallet not syncing", "appliedIndex", "isStrictlyComplete" | references/wallet-lifecycle.md § Sync stalls |
| "Custom error: 139", "transaction rejected by node", "Invalid Transaction" | references/symptom-catalog.md § err-139 |
| "deployContract hangs", "watchForTxData never returns", "deploy timed out" | references/wallet-lifecycle.md § Six-phase deploy |
| "403", "WAF", "ELB", "blocked from my server", "GCP/AWS/DO + Midnight" | references/network-chooser.md § Cloud-IP block |
| "wrong address", "faucet didn't arrive", "balance shows 0", "which address" | references/three-addresses.md |
| "DUST", "NIGHT registration", "dust generation", "dustReceiverAddress", "tDUST" | references/dust-night-registration.md |
| "verifier returns false", "vk mismatch", "Groth16 deploy verification" | run scripts/deploy-verifier.mjs; for incident response read references/groth16-vk-mismatch.md |
| "persistentHash", "SHA256 doesn't match", "hashlock mismatch", "cross-family" | references/cross-family-hashlocks.md |
| "ERR_UNSUPPORTED_DIR_IMPORT", "ESM in CJS", "@midnight-ntwrk import fails" | references/symptom-catalog.md § cjs-esm |
| "submitTx timeout", "tx >8KB fails", "AWS WAF on RPC" | references/symptom-catalog.md § waf-8kb |
| "snapshot won't restore", "GCS scope", "wallet warm-restart broken" | references/symptom-catalog.md § snapshot-gcs |
| "what proof-server version", "ledger-v7 vs v8", "SDK compat" | references/version-matrix.md |
Routing rules:
- One symptom, one doc. Never broadcast-load.
- If the user describes two symptoms, fix the one that blocks the other first (sync before deploy, network before sync).
- If no row matches, ask one clarifying question. Don't guess.
Three addresses
The single most-mis-applied concept in Midnight backend code. Inline here so triage doesn't require loading a reference doc.
One seed, three addresses
A single MIDNIGHT_SEED (32-byte or 64-byte hex) derives three distinct addresses through HDWallet.fromSeed(seed).selectAccount(0).selectRoles([Roles.Zswap, Roles.NightExternal, Roles.Dust]).deriveKeysAt(0). The roles enum is exported by @midnight-ntwrk/wallet-sdk-hd. Each role yields a separate keypair with a different on-chain semantic.
import { HDWallet, Roles } from '@midnight-ntwrk/wallet-sdk-hd';
import {
ZswapSecretKeys,
DustSecretKey,
} from '@midnight-ntwrk/ledger-v8';
import { createKeystore, PublicKey } from '@midnight-ntwrk/wallet-sdk-address-format';
import { MidnightBech32m, DustAddress } from '@midnight-ntwrk/wallet-sdk-address-format';
const seed = Buffer.from(process.env.MIDNIGHT_SEED!, 'hex');
const hd = HDWallet.fromSeed(new Uint8Array(seed));
if (hd.type !== 'seedOk') throw new Error(`HD seed failed: ${hd.type}`);
const derived = hd.hdWallet
.selectAccount(0)
.selectRoles([Roles.Zswap, Roles.NightExternal, Roles.Dust] as const)
.deriveKeysAt(0);
if (derived.type !== 'keysDerived') throw new Error(`derive failed: ${derived.type}`);
const networkId = 'preview'; // or 'preprod' / 'mainnet'
const zswapKeys = ZswapSecretKeys.fromSeed(derived.keys[Roles.Zswap]);
const dustSecret = DustSecretKey.fromSeed(derived.keys[Roles.Dust]);
const unshieldedKs = createKeystore(derived.keys[Roles.NightExternal], networkId);
const shieldedCoinPublicKey = zswapKeys.coinPublicKey; // 32-byte hex
const unshieldedAddress = unshieldedKs.getBech32Address().toString(); // mn_addr_<net>1...
const dustAddress = MidnightBech32m
.encode(networkId, new DustAddress(dustSecret.publicKey))
.toString(); // mn_dust_<net>1...
hd.hdWallet.clear();
Address shapes
| Role | Format | Example |
|---|---|---|
Roles.Zswap | 32-byte hex (no prefix in SDK; backend pads to 0x + 64 hex) | 4f1c...3a9b |
Roles.NightExternal | bech32m | mn_addr_preview1qwerty... / mn_addr_preprod1... |
Roles.Dust | bech32m | mn_dust_preview1... / mn_dust_preprod1... |
Operation, address, why
| Operation | Use this address | Why |
|---|---|---|
Faucet POST address field | unshielded bech32m | Faucet drops land in the unshielded UTXO set |
| Lace "Receive" tab display | unshielded bech32m | Lace's UI is unshielded-default |
| Indexer balance query | unshielded bech32m | Public chain state is keyed on unshielded |
| Native NIGHT transfer (send) | unshielded bech32m | NIGHT lives unshielded |
Shielded zswap tx, coinPublicKey API params | shielded hex (Zswap) | Shielded ledger uses coin public keys |
HTLC sender / receiver Bytes<32> arg | shielded hex (Zswap) | HTLC contract takes raw 32-byte keys |
| DUST balance lookup, dust-credit recipient | dust bech32m | Dust accrual uses the dust public key |
dustReceiverAddress arg to registerNightUtxosForDustGeneration | dust bech32m | Designation targets the dust address |
The getWalletAddress() trap
Many wallet adapter wrappers expose a getWalletAddress() method that returns ONLY the shielded coin public key (32-byte hex padded to 0x + 64 hex). Backend code that says "the wallet address" almost always means this one. It's correct for HTLC sender/receiver args, and wrong for everything balance-related (faucet, Lace, indexer).
When in doubt, expose all three on a diagnostics endpoint and pick by use case:
{
shieldedCoinPublicKey: '0x4f1c...3a9b',
unshieldedAddress: 'mn_addr_preview1...',
dustAddress: 'mn_dust_preview1...',
}
Seed normalization gotcha
A 24-word BIP39 mnemonic can be normalized into a seed three ways. Only one matches Lace.
| Normalization | Bytes | Matches Lace? |
|---|---|---|
bip39.mnemonicToSeedSync(mnemonic, '') (PBKDF2 full) | 64 | YES |
| First 32 of PBKDF2 output | 32 | NO |
bip39.mnemonicToEntropy() (BIP39 entropy) | 32 | NO |
If your derived addresses don't match what Lace shows, you almost certainly used the wrong normalization. The bundled scripts/address-derive.mjs runs the canonical PBKDF2-full path.
For deeper diagnostics (faucet didn't arrive, balance still zero, address mismatch), load references/three-addresses.md.
Quick fixes
Top twelve errors. One-line diagnosis, minimum-viable fix. If the fix doesn't stick, escalate to the deeper doc.
Custom error: 139
Diagnosis: wallet submitted a transaction before chain sync completed. Node rejected stale UTXO inputs.
Fix:
await waitForWalletSyncState(WALLET_SYNC_TIMEOUT_MS, 'startup');
// only then: submitTx, deploy, lock, etc.
Deeper: references/wallet-lifecycle.md § Sync-completion check.
ERR_UNSUPPORTED_DIR_IMPORT from @midnight-ntwrk/*
Diagnosis: @midnight-ntwrk/* packages are ESM-only. CJS require() fails on bare-directory imports.
Fix:
// In a CJS file, use type-only static imports + dynamic import for runtime.
import type { WalletFacade } from '@midnight-ntwrk/wallet-sdk-facade';
const { WalletFacade } = await import('@midnight-ntwrk/wallet-sdk-facade');
Deeper: references/symptom-catalog.md § cjs-esm.
submitTx times out around 25-30s, tx is ~90 KB
Diagnosis: the SDK's HTTP submitTx posts to RPC. AWS WAF rejects bodies >8 KB. Deploy txs are typically 50-100 KB.
Fix: route submission through wallet.submitTransaction(tx). That uses the WebSocket relay (PolkadotNodeClient), bypassing the HTTP body limit.
const midnightProvider = {
async submitTx(tx) { return wallet.submitTransaction(tx); },
};
Deeper: references/symptom-catalog.md § waf-8kb.
403 Forbidden from https://rpc.preprod.midnight.network (only on cloud VMs)
Diagnosis: