Preprod: Custom error 173 + "Wallet.InsufficientFunds: could not balance dust" despite large dust.balance(now) on shared bridge claimer

What are we trying to do?

Build a production bridge / capacity-exchange service on Midnight Preprod that uses a shared bridge claimer wallet to submit Ethereum- and BNB-originated claim transactions on Midnight (via @midnight-ntwrk/midnight-js ethereum-bridge-claim). The same claimer seed is used by three scripts:

  • register-bridge-claimer-dust.ts — registers unshielded coins for dust generation
  • check-bridge-claimer-dust-balance.ts — read-only dust snapshot
  • live claim submit path: submitEthereumClaimReal → sdkSubmitEthereumClaim → buildEthereumSdkProviders → getEthereumBridgeClaimerSeed() + initFacadeWallet

Claimer address (shared between all three paths):
mn_addr_preprod1yr0f5cqps3rmtfarhtkrrhqka5slrufax46uy3w333e7y63xarqsuddncn

What’s not working or unclear?

Two related errors, both on Preprod:

1. Dust-registration submit fails with Custom error 173

RPC-CORE: submitAndWatchExtrinsic(extrinsic: Extrinsic): ExtrinsicStatus:: 1010: Invalid Transaction: Custom error: 173
[info] Dust submit returned non-fatal node response: Transaction submission error

I’ve seen this same error in the #dev-chat thread “If a wallet has sufficient DUST balance” (by Cadalt, 04-04-2026). In that thread norm explained the related 170 error is caused by a 1–3h gap between root pruning in root_history (~1h) and OutOfDustValidityWindow kicking in (~3h). It’s unclear whether 173 is in the same family, a different check, or something else — please confirm.

2. Live claim submit fails with Wallet.InsufficientFunds: could not balance dust

After facade.waitForSyncedState() + dust.waitForSyncedState() complete, a read-only snapshot of the same claimer shows:

  • usable_dust_balance_specks: 9225916793000000000 (large, non-zero)
  • Unshielded summary: {"totalCoins":2,"registeredCount":2,"unregisteredCount":0,"unregisteredValueSum":"0"}

A separate run of register-bridge-claimer-dust.ts after long sync confirmed two coins both registered: totalCoins: 2, registeredCount: 2, unregisteredCount: 0.

Yet a live BNB claim submit against this same wallet fails with:

Wallet.InsufficientFunds: could not balance dust

Originating from @midnight-ntwrk/wallet-sdk-dust-wallet / the transacting path used when balancing the scoped claim transaction.

Live BNB smoke evidence:

  • Deposit id: bnb_7eb008c69866af1fdfb63c4b643459d40ceadc30e3c9c7b2caf01b955e1963a9_21
  • EVM tx hash: 0x7eb008c69866af1fdfb63c4b643459d40ceadc30e3c9c7b2caf01b955e1963a9
  • Observed state: CREDIT_READY; process reaches real Midnight ethereum-bridge-claim submission before failing.

What have you tried so far?

  • Confirmed the seed is identical between registration, read-only balance check, and submit (same getEthereumBridgeClaimerSeed() + initFacadeWallet everywhere).
  • Resolved a prior INVALID_SIGNATURE BNB attestation issue (witness/message alignment) — not the current failure.
  • Ruled out “no dust at all” — dust.balance(now) shows a large value.
  • Let dust.waitForSyncedState() run for several minutes on Preprod and re-checked — dust is visible and registered.
  • Read the #dev-chat Discord discussion where norm (Midnight mod) and Facu | Midnames advised on the 170 variant: “keep the wallet synced and submit fast. If the wallet sat idle between building the proof and submitting, that’s usually the cause.” I will try tightening our submit-after-proof window, but our service is a long-lived shared claimer that must handle bursts of claims, so a “submit within seconds of sync” pattern is fragile for production.
  • Verified the SDK-known isStrictlyComplete() hang on idle chains; using index check (appliedIndex >= highestRelevantWalletIndex) as backup.

Any relevant code or error messages?

Key error, verbatim, from the POST .../process call:

Wallet.InsufficientFunds: could not balance dust

Key error from dust-registration submit:

1010: Invalid Transaction: Custom error: 173
[info] Dust submit returned non-fatal node response: Transaction submission error

Full evidence packages attached:

  • MIDNIGHT_DUST_USABILITY_BLOCKER.md
  • 173-PREPROD-SUPPORT-POST.md

Questions for the Midnight team

  1. Is Custom error 173 in the 1010 extrinsic family the same root-pruning / OutOfDustValidityWindow cause as 170, or a different check? What does 173 specifically indicate?
  2. How should operators reconcile a large dust.balance(now) snapshot with a Wallet.InsufficientFunds at balancing on the same wallet, same moment, same seed? What is the dust-wallet balancer actually checking that dust.balance(now) is not?
  3. Is there a recommended pattern for a long-lived, always-on shared bridge claimer on Preprod/Mainnet — one that doesn’t rely on “build proof and submit within seconds”? E.g. a sync-before-each-submit pattern, a keepalive pattern, or a scoped-transaction rebuild on every attempt?
  4. Will the upcoming V5 indexer (server-side viewing-key scan) or the larger-proof allowance mentioned in #dev-chat change any of the above?

Where are you asking from?

  • Network: Preprod
  • SDKs: @midnight-ntwrk/midnight-js, @midnight-ntwrk/wallet-sdk-dust-wallet (exact versions in attached files)
  • Proof server: self-hosted, docker run -p 6300:6300 midnightntwrk/proof-server:latest
  • Service: shared bridge claimer for Ethereum + BNB, capacity-exchange rollout currently paused pending this clarification

Happy to provide more logs, a minimal repro, or a private session if useful.

Custom error 173 is InsufficientDustForRegistrationFee. It’s in the dust error family (169-173) but is not the same root-pruning cause as 170 or 171. The registration’s allow_fee_payment amount exceeded the available unclaimed funds for that key in the UTXO state. Check that your claimer has enough dust generated before the registration call, not just tNIGHT.

Full dust error family for reference:

  • 169 = InvalidDustRegistrationSignature
  • 170 = InvalidDustSpendProof (the root-pruning one)
  • 171 = OutOfDustValidityWindow (the 1-3h gap one)
  • 172 = MultipleDustRegistrationsForKey
  • 173 = InsufficientDustForRegistrationFee

On the InsufficientFunds despite large balance: dust.balance(now) reports total dust value but the balancer needs to construct spend proofs against specific UTXOs whose merkle roots must still be in root_history. If the wallet sat idle, those roots get pruned (~1h), so the balance reads fine but nothing is actually spendable. A resync rebuilds the wallet’s view against current roots.

For a long-lived shared claimer, one approach suggested in dev-chat was resync-before-each-submit: save wallet state to disk, resync the delta before each claim, then submit immediately. That said, there’s no established pattern for production bridge claimers yet. Flagging this to the team for guidance on recommended architecture.

On V5 indexer and larger proof allowance — don’t have visibility on whether those address these specific issues. Routing that to the team as well.

Sources:

1 Like

Another thing you could try is to get multiple NIGHT UTXOs registerd for DUST generation, so you can concurrently manage at least a few users without requiring to resync the wallet or wait for transaction acceptance. I’m not 100% how to do this, but I think this is the correct way of doing this.

1 Like

Thank you this is very helpful. The error-enum and dust.rs links clarified the 169-173 family in a way the SDK surface doesn’t expose yet.

So if I understand correctly:

  1. Our 173 on dust registration is specifically InsufficientDustForRegistrationFee, not the root-pruning / validity-window family.
  2. Our later Wallet.InsufficientFunds: could not balance dust during live claim submit is likely a separate spendability issue, where dust.balance(now) can still look large while the balancer cannot construct a currently spendable proof set.

That matches what we’re seeing.

Two follow-ups:

  1. Is there an SDK-supported way to distinguish “total dust balance” from “currently spendable dust for balancing right now” on the same wallet?
  2. For a long-lived shared bridge claimer, is “resync immediately before each submit, then submit right away” the recommended pattern for now, or is there a better production architecture the Midnight team recommends?

Happy to be a reference implementation / test case for the long-lived claimer pattern if that would help the team design against a real production use case — can document our setup and the specific failure modes we’ve hit.

Appreciate this _ that’s a really helpful direction.

Combined with the earlier resync-before-submit suggestion, it sounds like a production shared claimer may need more than a single registered NIGHT source, so it can handle multiple claims without every attempt racing the same spendability / root-history constraints.

a couple of follow-ups on that pattern:

  1. Is the intended approach to maintain a pool of multiple NIGHT UTXOs already registered for DUST generation, rather than relying on one registered source?
  2. If so, is there SDK support for influencing which registered source / UTXO gets used when balancing a claim, or does the balancer always choose automatically?
  3. If there is no UTXO-level control today, would the recommended production pattern be multiple sub-wallets / claimers, each with its own registered NIGHT source?

If nobody has documented this pattern yet, happy to prototype it on our side and share what works — we’ll need it anyway for a real production bridge claimer.

Update: successful end-to-end BNB → Midnight claim on Preprod, indexer-confirmed. Here’s what the path looked like and what I learned.

What succeeded

The previously-failing BNB deposit was successfully claimed on Midnight:

  • Deposit: bnb_7eb008c69866af1fdfb63c4b643459d40ceadc30e3c9c7b2caf01b955e1963a9_21
  • SDK-returned tx identifier: 001fb1c03b784b98e45c7acef72c9e00c3639d864d15a1145e15da0a832072638c
  • On-chain block hash: 9efd79cb833992e6b725f2d737627bcaa1141a18badec8d67bb245afdfa2e4bd
  • Block height: 437820
  • Submit latency: ~20s end-to-end via warm claimer daemon

Verified by querying the v3 indexer GraphQL directly with transactions(offset: { identifier: $txId }). The explorer UI didn’t resolve the identifier directly (404), which is a separate indexing question, but the underlying chain state is confirmed.

What I built

A long-running daemon wrapping the facade wallet, with:

  • HTTP /health, /ready, /status, /claim endpoints
  • Bounded initial sync (strict → index-fallback → best-effort)
  • Continuous poll loop on facade.state() (3s default)
  • Readiness gate: dust caught up, usable dust > 0, state fresh, bounded consecutive read failures
  • Graceful shutdown via SIGINT/SIGTERM
  • Warm claim path that reuses providers instead of rebuilding per request

What I observed

  1. Cold-start dust sync works in a single long-lived process.
    In watch mode, the dust layer caught up from far behind to tip within ~15 minutes. usable_dust_balance_specks became nonzero within ~60s of watch loop start.

  2. Cross-process dust progress is misleading.
    Re-running one-shot CLI diagnostics made dust applied look inconsistent between invocations (it appeared to move backward). Watching one persistent process showed monotonic forward progress instead. Takeaway: cold-start sync state cannot be compared across separate invocations.

  3. The warm daemon pattern appears to be the right operational shape.
    Per-operation CLI cold starts consume the full sync window before the claim can be built. A long-lived process lets the readiness gate and warm providers work together.

  4. The earlier Wallet.InsufficientFunds: could not balance dust failure may have been related to dust spendability timing / warm-state availability in the SDK.
    I can’t confirm the root cause definitively without more instrumentation. What I can say is that the warm daemon pattern resolved it for this deposit.

  5. The readiness gate saved me from a blind retry cycle.
    At one point during a ~16h daemon session, usable_dust_balance_specks went to 0 even though dust layer indexes stayed caught up. The daemon correctly returned 503 daemon_not_ready reason usable_dust_balance_zero instead of attempting a doomed submit. Spendability recovered later during the same process without a restart — I don’t yet fully understand those dynamics.

Current operational pattern

For my production use case, the strongest pattern so far:

  • Keep the claimer alive continuously
  • Maintain synced/warm wallet state
  • Gate claim submission on explicit readiness (dust caught up, usable dust > 0, recent successful reads)
  • Submit immediately once ready

Follow-up questions

  1. Does this align with the Midnight team’s expected operational model for a production bridge claimer?
  2. What’s the recommended strategy for keeping spendability warm over long runtimes, especially during extended idle periods? I observed recovery without intervention but don’t understand the mechanism.
  3. Is there a canonical way to reconcile SDK-returned txId with the block.hash field the indexer uses? My explorer lookups failed on the identifier but the indexer resolved it fine — would be useful to document for other operators.
  4. If I turn this into a reference write-up for the ecosystem, is there existing team guidance I should align with first?

Happy to share the daemon pattern and empirical findings as a cleaner reference write-up if that would be useful.

@norm @faculerena — thanks for the earlier guidance. This result wouldn’t exist without the root-pruning explanation and the multi-UTXO suggestion that shaped the architecture.