Bicrypto 6.5.0

2 July 2026

WALLETUTXOWITHDRAWALSDEPOSITSMONEY-SAFETYBATCHINGFEESRPCSTABILITYLOGGINGBUG-FIXES

Core v6.5.0

Release Date: July 2, 2026 Tags: WALLET, UTXO, WITHDRAWALS, DEPOSITS, MONEY-SAFETY, BATCHING, FEES, RPC, STABILITY, LOGGING, BUG-FIXES

Overview

Version 6.5.0 is a ground-up rework of the on-chain withdrawal and deposit engine for UTXO chains (BTC, LTC, DOGE, DASH) in the Ecosystem extension. Withdrawals are now funded from a platform-wide UTXO pool instead of each user's personal deposit history, several queued withdrawals are batched into a single on-chain transaction (one network fee shared by all recipients), and change outputs are recorded locally and atomically instead of being re-fetched from a block explorer.

That closes the two failures users actually hit: a withdrawal rejected with "No UTXOs available" against a balance that was real, and a successful withdrawal silently stranding the change. It also ends the runaway JsonRpcProvider failed to detect network log flood, which one unreachable EVM endpoint could write once per second until the disk filled.

Update Instructions

The usual pnpm updator is all that is required.

Two configuration notes:

  • Review your withdrawal fee settings. The pool pays the on-chain miner fee; the platform's margin is the configured token withdrawal fee (percentage / minimum) minus that miner fee. Batching shrinks the per-user miner fee substantially, but a token configured with a zero withdrawal fee is a slow pool drain. Set the fee so it covers the typical network fee.
  • UTXO_BATCH_WINDOW_MS (optional, default 0) — withdrawals that are queued together are always batched together at no added latency. Setting a window (e.g. 300000 for 5 minutes) additionally parks a lone withdrawal for up to that long so later withdrawals can join the same transaction; a newly arriving companion releases parked withdrawals immediately.

Two operational notes:

  • The withdrawal queue is an in-memory, strictly serial worker — run a single backend instance (pm2 fork mode), as before.
  • Recommended follow-ups for large deployments: a periodic solvency check (sum of ledger balances vs. sum of pool UTXOs per currency, alerting on drift), and a unique database index over each UTXO's wallet, transaction and output position once existing data is verified duplicate-free.

Added

Batched withdrawals

One transaction, many recipients

The withdrawal queue now drains all queued withdrawals for the same chain and currency into one on-chain transaction — up to 20 recipient outputs plus a single change output — so the network fee is paid once and shared by every recipient. Under load this is a large fee saving that accrues directly to the platform.

Failure isolation is per member: a withdrawal with a dust-level amount or an invalid address is failed (and refunded) alone while the rest of the batch proceeds; a whole-batch failure degrades to individual processing so one bad member or partial pool liquidity can't take everyone down; and failures are attributed to the member that actually failed, not whichever happened to be first in the batch.

Recipient addresses are validated before funds move

Recipient addresses are now parsed against the actual network before any coins are selected — previously BTC-family addresses were only checked to be alphanumeric, and a bad address failed deep inside transaction building.


Changed

Withdrawals are funded from a platform-wide UTXO pool

Trade-acquired balances are now withdrawable

Previously, UTXO selection was scoped to the withdrawing user's own wallet. A user who received BTC by trading (e.g. exchanged ETH → BTC) had, by definition, no UTXOs on their own deposit address — the ledger said they owned BTC, but the withdrawal engine could not find coins to spend and rejected with "No UTXOs available". The only workaround was making on-chain deposits first, which no real exchange requires.

All unspent UTXOs of a currency — across every user's deposit address — now form a single spending pool. A withdrawal is built as a PSBT whose inputs may come from many wallets, each input signed with its own wallet's key. The user's entitlement is enforced where it always was: the ledger balance check and debit (with row locks and the audit log) at request time. Selection is largest-first with a two-phase candidate/lock design that row-locks only the chosen UTXO rows — never wallet rows — so withdrawals don't contend with trading.

Pool-wide consolidation

Automatic consolidation of small UTXOs (which reduces the input count, and therefore the fee, of future withdrawals) now also operates on the whole pool instead of one wallet. A withdrawal that triggers consolidation no longer blocks for up to 30 minutes waiting for the consolidation to confirm — the consolidated output is recorded immediately and spent unconfirmed in the same flow.

Money-safety: broadcast is the point of no return

Batching multiplies the blast radius of any re-broadcast bug (N recipients paid twice instead of one), so this release makes "never pay twice" a structural guarantee rather than a happy-path property:

  • No path can re-broadcast. Any withdrawal whose row already carries a broadcast hash (or is COMPLETED) is dropped from every batch, retry, and recovery path before coins are selected.
  • Crash-safe broadcast window. The transaction id is computed before broadcasting and persisted as a broadcast intent outside the database transaction. If the process dies between broadcast and commit, boot/watchdog recovery now checks the chain for that id — promoting the withdrawal to COMPLETED and settling its inputs if found — instead of blindly reverting to PENDING and paying the recipients a second time.
  • Broadcast timeouts are probed, not refunded. A broadcast request that errors (e.g. a timeout) after the node already accepted the transaction used to fail the withdrawal and refund the user — a double payout. The engine now probes the chain for the precomputed txid before declaring failure.
  • Post-broadcast bookkeeping failures self-repair. If persisting the results fails after a successful broadcast, the input marks and the change row are re-persisted outside the rolled-back transaction (for both withdrawals and consolidations) — previously the rollback erased them, leaving on-chain-spent rows marked spendable (poisoning every subsequent withdrawal) and the change unrecorded.
  • Refund paths hardened. A duplicate refund attempt (already idempotent at the ledger) can no longer escape as an unhandled rejection; confirmation emails and fee collection run outside any retry boundary so a mail/Redis hiccup after a successful broadcast can't re-enter processing; and the recovery sweep no longer reverts rows the live worker is still building.

Fixed

Change outputs are recorded locally and atomically

The old flow re-fetched the just-broadcast transaction from the explorer to locate the change output. mempool.space indexes a new transaction a few seconds after accepting it, so this fetch regularly returned HTTP 404 — the change UTXO row was silently never written, the platform lost sight of the remaining coins, and the next withdrawal failed with "No UTXOs available" even though the funds were on-chain the whole time.

The transaction id, the change output's index, its script, and its amount are all known locally at build time. The change row is now written inside the same database transaction that persists the broadcast hash and locks the spent inputs — committed together, immune to explorer lag. The consolidation output is recorded the same way. (Raw-transaction fetches used during building also gained a retry for the same indexing-lag window, which is what allows spending unconfirmed change immediately.)

Deposit detection and crediting

Multiple deposits per session are detected

The deposit-page monitor used to stop permanently after the first confirmed deposit — a second deposit in the same session was invisible until the page was re-opened (the cause of "it took three deposits before withdrawal saw any UTXO"). It now keeps polling for the whole session window.

Missed UTXOs are backfilled automatically

Before a withdrawal is rejected for lack of pool liquidity, the wallet's own deposit address is reconciled against the chain and any confirmed unspent output missing from the database is backfilled (throttled to once per wallet per minute). This retroactively rescues wallets affected by the stranded-change bug and by deposits whose recording was missed.

Change can never be credited as a deposit

With pooled spending, change is paid to some depositor's address — and the deposit monitor would have seen it as an incoming transaction and credited it as new money (a ledger mint out of thin air; the same hole existed for consolidation outputs). Every UTXO now records how it came to exist — deposit, change, consolidation or sync — and the deposit flow excludes platform-produced outputs from deposit credit. Deposit UTXOs are also recorded idempotently, so concurrent monitors can't duplicate an outpoint — and duplicate outpoints are additionally collapsed at selection time.

Self-healing UTXO bookkeeping

  • Stale rows are actually detected now. A row marked unspent in the database but already spent on-chain used to jam withdrawals forever: the cleanup relied on per-transaction "spent" flags that mempool.space and Bitcoin-node providers never populate, so it was a permanent no-op. Spent-ness is now decided by comparing suspect rows against the owner address's live UTXO set, with an outage probe so an explorer failure can never mass-mark healthy coins.
  • Node rejection reasons reach the error handlers. Provider HTTP errors previously discarded the response body, reducing every rejection to HTTP 400: Bad Request — so the spent-input retry classifier never matched. Error bodies (e.g. bad-txns-inputs-missingorspent, too-long-mempool-chain) are now included.
  • Mempool chain limits requeue instead of refunding. Hitting the 25-unconfirmed-ancestor limit (many withdrawals between two blocks) now defers the withdrawal for the watchdog to retry after the next block, instead of failing and refunding a perfectly good withdrawal.
  • Fee estimation is output-aware. Recipient outputs are sized by their actual script type — taproot/segwit outputs are larger than the legacy size previously assumed, which could push a batch below the minimum relay feerate (rejected broadcast) or below the selected priority.
  • Transaction verification uses the right network. Transaction confirmation polled a legacy BlockCypher testnet3 URL for testnet4 transactions, so it could never confirm — which also stalled deposit finalization. It now goes through the network-aware provider chain.
  • DASH conversions fixed. DASH was missing from the satoshi conversion table, so every DASH amount evaluated to NaN and every DASH withdrawal was dead on arrival; unknown chains now fail loudly instead of NaN-poisoning amounts.

RPC provider stability — the "failed to detect network" flood is gone

Root cause

ethers v6 has a sharp edge: an RPC or WebSocket provider created without a pinned network retries network detection against an unreachable node every second, forever, printing JsonRpcProvider failed to detect network and cannot start up; retry in 1s on each attempt — and every request queued on that provider hangs indefinitely, so the calling code's error handling never runs. Only destroying the provider stops the loop. Several places created providers this way and abandoned them: the admin System Health checks probed each configured chain with a throwaway provider (and hung instead of reporting "down" when one was unreachable — every dashboard poll then spawned another eternal logging loop), the admin blockchain balance endpoint created one per request (with a guaranteed-dead fallback URL for some chains), and the Ecosystem WebSocket provider was leaked undestroyed when its connection timed out.

Every provider is now pinned, bounded, or destroyed

  • Health checks no longer use ethers providers at all. The System Health and batch-health endpoints probe RPC and WebSocket endpoints with plain JSON-RPC calls under a hard timeout, so a dead endpoint is reported as Down in seconds instead of hanging the endpoint and flooding the logs. Connection failures now report the real reason (ECONNREFUSED, ETIMEDOUT, DNS) instead of an opaque fetch failed, and a long-standing ARBIRUM_MAINNET_RPC env-variable typo is fixed (the correctly spelled ARBITRUM_MAINNET_RPC is read first; the misspelled name still works as a fallback).
  • Everything else pins its network. The per-request admin balance provider and the Ecosystem WebSocket provider now pin the chain id up front (no detection, no loop), carry hard timeouts, and are destroyed on every exit path — including the connection-timeout path that previously leaked a live, retrying provider. Provider health checks are bounded (10s) so a node that accepts connections but never answers can't park deposit monitoring forever, and concurrent WebSocket-provider initializations for the same chain are deduplicated so racing reconnects can't orphan an open socket.

Log-flood backstop

Even with every known source fixed, a future regression (or a misconfigured custom chain) shouldn't be able to take the server down with log volume. A process-level guard now throttles exactly this ethers retry message, on every log stream, in the main process and every worker thread: the first occurrence prints, then at most one summary line per minute ([ethers] suppressed N repeated network-detection retry logs…). All other log output is untouched.

Deposit monitors stop cleanly and back off

The same review covered the deposit-monitoring loops that sit on top of these providers:

  • MO token deposits — the error-recovery path re-armed the polling loop incorrectly: while the RPC was down it retried the setup probe every 10 seconds with no backoff (a per-address log flood), and each recovery stacked an extra polling interval that could never be stopped (duplicate deposit processing that only a restart cleared). Recovery now resumes the same tick loop with exponential backoff, overlapping ticks are guarded, and stopping the monitor also cancels a pending backoff — polling can no longer resurrect after a client disconnects.
  • TON deposits — the per-address poller had no stop mechanism at all and rescheduled itself unconditionally: every wallet ever monitored kept hitting the TON API (and logging failures) every 60 seconds for the life of the process. The loop is now gated by a per-session token (so closing the deposit page actually ends it, and a quick stop/restart can't double it up), errors back off exponentially, and ten consecutive failures stop the loop instead of hammering a dead API forever.
  • EVM token deposits — each watch session stacked another permanent error handler on the shared chain provider (a leak that also multiplied reconnect attempts), reconnects could race each other into orphaned sockets, and a stale post-deposit cleanup timer could strip a newer session's listeners. Listeners are now tracked per monitor and detached on stop, only one reconnect runs at a time, the cleanup timer is cancelled on stop/reconnect, and the once-per-second bootstrap error a broken provider emits is logged at most once per minute.