Repository navigation
platform-wallet: confirmed UTXO height never mirrored back from core_transactions — asset-lock funding fails with "No UTXOs available" after restart #4178
Description
TL;DR: A UTXO that arrives unconfirmed and later confirms on-chain gets stuck marked "unconfirmed" forever in the wallet's local storage, permanently blocking Platform identity funding from that UTXO even though the funds are fully confirmed and spendable everywhere else.
User story
As a Dash Platform wallet user, I want a UTXO that has genuinely confirmed on-chain to remain usable for funding my identity, so that my confirmed balance doesn't silently fail when I try to spend it on identity creation or top-up.
As a wallet integrator (e.g. dash-evo-tool), I want platform-wallet-storage to persist UTXO confirmation state correctly across restarts, so that my app doesn't need special-case workarounds to detect and recover stranded funds.
Scenario
Base flow
A wallet receives funds to one of its addresses. It detects the transaction immediately while still in the mempool, and later observes it confirm in a block — all while the wallet process keeps running.
Actual behavior
The wallet's local database never updates that UTXO's confirmation status past "unconfirmed," even though the transaction is fully confirmed on-chain and in the wallet's own transaction history. On the next wallet restart, the UTXO reloads as non-final.
Ordinary sends still work fine (they don't check finality), and the wallet's visible balance is correct — but funding a Platform identity from that UTXO fails outright with "No UTXOs available for selection," because identity funding requires finalized inputs. The funds are effectively stranded for identity-funding purposes until the user happens to spend the UTXO some other way (which incidentally resets it).
Expected behavior
Once a UTXO's funding transaction confirms on-chain, the wallet's persisted state should reflect that confirmation, so the UTXO remains usable — including by finality-gated operations like identity funding — across any number of restarts.
Detailed discussion
Root Cause
Confirmed against the pinned revs (platform-wallet/platform-wallet-storage @ d18020f526e2a8eb1d1e868b436a7a9735795abb; dash-spv/key-wallet @ be6e776d69af9f31ec622898176e4b33bdc969d3):
-
Mempool detection persists the UTXO unconfirmed. A live mempool match emits
WalletEvent::TransactionDetected(key-wallet/src/transaction_checking/wallet_checker.rs:275). The bridgebuild_core_changeset(platform-wallet/src/changeset/core_bridge.rs:131) derivesnew_utxoswithheight = 0sinceblock_info()isNoneat that point. This is correct behavior for a mempool-only tx. -
Confirmation is classified as an
updatedrecord, notinserted. When the tx confirms,wallet_checker::check_transactionfinds it already known (is_new = false) and routes it intoupdated_records(wallet_checker.rs:197-212), surfacing asWalletEvent::BlockProcessed { updated, .. }. -
The bug:
build_core_changeset'sBlockProcessedarm (core_bridge.rs:185-196) only re-derivesnew_utxos/spent_utxosforinsertedrecords:for r in inserted { // <-- inserted only cs.new_utxos.extend(derive_new_utxos(r)); cs.spent_utxos.extend(derive_spent_utxos(r)); } cs.records.extend(inserted.iter().cloned()); cs.records.extend(updated.iter().cloned()); // updated -> records, NOT new_utxos cs.records.extend(matured.iter().cloned());
updated/maturedrecords are copied intocs.records(soplatform-wallet-storage'score_state::applycorrectly writes the confirmed height intocore_transactions,core_state.rs:68) but never regenerate acore_utxosheight update. The confirmation is on disk — just on the wrong table. -
Reload trusts only
core_utxos.height.core_state::load_state(core_state.rs:310) computesconfirmed = height.map(|h| h > 0).unwrap_or(false), with no cross-check againstcore_transactions(which does have the correct confirmed state for the same txid). -
dash-spvdoes not self-heal this. There is no per-address "scanned-through height" marker, so a rehydrated wallet whose chain-scan checkpoint has already passed the funding height never re-emits aBlockProcessed/TransactionDetectedevent that would repair it (Filters: … matched: 0for the whole session in the observed reproduction). This is a secondary, narrower gap — see Possible Solution.
Empirical Evidence
Found while investigating a dash-evo-tool user report: unable to create a Dash Platform identity despite the wallet showing sufficient balance from a prior confirmed withdrawal. Independently verified against Insight (funding tx confirmed at block 1518288) and against the wallet's own platform-wallet.sqlite: the funding UTXO (value = 100000000 duffs = 1 DASH) has core_utxos.height = 0, while core_transactions shows the same txid at height = 1518288, finalized = 1. Two additional UTXOs in the same database exhibit the identical pattern (core_transactions.height = 1518735, finalized = 1 vs. core_utxos.height = 0), indicating this is systemic rather than a one-off.
Possible Solution
The confirmed height is already delivered to platform-wallet-storage (inside cs.records, landing in core_transactions) — the storage layer just never mirrors it onto core_utxos. This means the fix can be fully contained in platform-wallet-storage, without depending on a platform-wallet or dash-spv change for the primary symptom:
- Load-time reconciliation (
platform-wallet-storage/src/sqlite/schema/core_state.rs,load_state): for an unspent UTXO withheight == 0, look up its txid incore_transactions; if a confirmed record exists, backfillheight/is_confirmedfrom it. Heals every already-stuck wallet on next launch, no migration. - Apply-time propagation (same file,
apply): when applying aCoreChangeSetwhoserecordscarry a confirmedblock_info, issue a targetedUPDATE core_utxos SET height = … WHERE spent = 0 AND height = 0 AND <matching txid>so the disk state stays correct going forward. - Deliberately not recommended: re-deriving
new_utxosforupdated/maturedrecords directly inplatform-wallet's bridge.core_state::apply's UTXO upsert doesspent = excluded.spenton conflict, so re-emitting anew_utxosentry for an already-spent output would resetspent = 0— a resurrection hazard. Any bridge-side fix should be scoped to height only, never a full UTXO re-insert. - Secondary/optional,
dash-spv: a birthday-bounded rescan for rehydrated addresses lacking a per-address scanned marker, to cover the narrower case where the confirmation never reachedcore_transactionsat all (app offline through the entire confirmation window).
Steps to Reproduce
- Create/restore a wallet; note an address controlled by it.
- Receive funds to that address and let the wallet detect the transaction while still unconfirmed (mempool detection).
- Let the transaction confirm on-chain while the wallet keeps running (so
BlockProcessed/updatedfires and is persisted). - Restart the wallet process (fresh load from the persisted SQLite store).
- Attempt to fund a Platform identity (or any operation using
require_final_inputs()) using that UTXO as the sole/primary funding source. - Observe
SelectionError::NoUtxosAvailableeven though the wallet's visible balance includes the funds and the funding transaction is fully confirmed on explorers.
Environment
platform-wallet/platform-wallet-storage:4.0.0, git revd18020f526e2a8eb1d1e868b436a7a9735795abbdash-spv/key-wallet:0.45.0, git revbe6e776d69af9f31ec622898176e4b33bdc969d3(dashpay/rust-dashcore)- Consumer:
dash-evo-tool(testnet), but the defect is entirely withinplatform-wallet/platform-wallet-storage— not DET-specific.
Attribution
🤖 Co-authored by Claudius the Magnificent AI Agent
Prior work
feat/platform-wallet-storage-rehydration— fixes a different, unrelated defect: anAssetLockProofbincodedecode failure (AnyNotSupported) that fails an entire walletload()outright (tracked separately as PR rs-platform-wallet-storage: AssetLockProof blobs can be written but never read back (bincode/serde deserialize_any incompatibility) #4133). Does not touchcore_bridge.rs'sBlockProcessedhandling orcore_state.rs's UTXO confirmation logic.pr4117-codex-fix— restores rehydration bookkeeping for a different pool entirely (masternode provider keys), unrelated code path.- kotlin-sdk/platform-wallet: duplicated unspent TXO rows after SPV rescan following unclean shutdown (inflated balance) #4069 / fix(platform-wallet): freeze sync watermark on persistence fault — TXO loss/duplication (#4069) #4071 — TXO duplication/loss caused by a lagging broadcast channel during rescan; a different failure mode (dropped events) in the same general area (
core_bridge.rschangeset construction).
Activity
lklimek commentedon Jul 22, 2026
Schema change - store UTXOs in one place only and handle it using relationship with the block height. To verify.