Skip to content

platform-wallet: confirmed UTXO height never mirrored back from core_transactions — asset-lock funding fails with "No UTXOs available" after restart #4178

Description

@Claudius-Maginificent

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):

  1. Mempool detection persists the UTXO unconfirmed. A live mempool match emits WalletEvent::TransactionDetected (key-wallet/src/transaction_checking/wallet_checker.rs:275). The bridge build_core_changeset (platform-wallet/src/changeset/core_bridge.rs:131) derives new_utxos with height = 0 since block_info() is None at that point. This is correct behavior for a mempool-only tx.

  2. Confirmation is classified as an updated record, not inserted. When the tx confirms, wallet_checker::check_transaction finds it already known (is_new = false) and routes it into updated_records (wallet_checker.rs:197-212), surfacing as WalletEvent::BlockProcessed { updated, .. }.

  3. The bug: build_core_changeset's BlockProcessed arm (core_bridge.rs:185-196) only re-derives new_utxos/spent_utxos for inserted records:

    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/matured records are copied into cs.records (so platform-wallet-storage's core_state::apply correctly writes the confirmed height into core_transactions, core_state.rs:68) but never regenerate a core_utxos height update. The confirmation is on disk — just on the wrong table.

  4. Reload trusts only core_utxos.height. core_state::load_state (core_state.rs:310) computes confirmed = height.map(|h| h > 0).unwrap_or(false), with no cross-check against core_transactions (which does have the correct confirmed state for the same txid).

  5. dash-spv does 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 a BlockProcessed/TransactionDetected event that would repair it (Filters: … matched: 0 for 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:

  1. Load-time reconciliation (platform-wallet-storage/src/sqlite/schema/core_state.rs, load_state): for an unspent UTXO with height == 0, look up its txid in core_transactions; if a confirmed record exists, backfill height/is_confirmed from it. Heals every already-stuck wallet on next launch, no migration.
  2. Apply-time propagation (same file, apply): when applying a CoreChangeSet whose records carry a confirmed block_info, issue a targeted UPDATE core_utxos SET height = … WHERE spent = 0 AND height = 0 AND <matching txid> so the disk state stays correct going forward.
  3. Deliberately not recommended: re-deriving new_utxos for updated/matured records directly in platform-wallet's bridge. core_state::apply's UTXO upsert does spent = excluded.spent on conflict, so re-emitting a new_utxos entry for an already-spent output would reset spent = 0 — a resurrection hazard. Any bridge-side fix should be scoped to height only, never a full UTXO re-insert.
  4. 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 reached core_transactions at all (app offline through the entire confirmation window).

Steps to Reproduce

  1. Create/restore a wallet; note an address controlled by it.
  2. Receive funds to that address and let the wallet detect the transaction while still unconfirmed (mempool detection).
  3. Let the transaction confirm on-chain while the wallet keeps running (so BlockProcessed/updated fires and is persisted).
  4. Restart the wallet process (fresh load from the persisted SQLite store).
  5. Attempt to fund a Platform identity (or any operation using require_final_inputs()) using that UTXO as the sole/primary funding source.
  6. Observe SelectionError::NoUtxosAvailable even 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 rev d18020f526e2a8eb1d1e868b436a7a9735795abb
  • dash-spv / key-wallet: 0.45.0, git rev be6e776d69af9f31ec622898176e4b33bdc969d3 (dashpay/rust-dashcore)
  • Consumer: dash-evo-tool (testnet), but the defect is entirely within platform-wallet/platform-wallet-storage — not DET-specific.

Attribution

🤖 Co-authored by Claudius the Magnificent AI Agent

Prior work

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions