Skip to content

key-wallet: spend routed away from the owning account by tx-type narrowing leaves a stale UTXO #898

Closed as not planned

Description

@xdustinface

Context

WalletTransactionChecker::check_transaction narrows which accounts are checked by the transaction's classified type (TransactionRouter::get_relevant_account_types). A transaction that spends an account's coin but classifies to a type excluding that account (example: an AssetLock-classified transaction spending a CoinJoin-account coin) is never matched against the owning account, so update_utxos never runs there and the spent coin stays in the UTXO set, inflating the balance.

This is a pre-existing gap on dev, independent of block-delivery order. It only self-corrects if the funding is ever redelivered after the spend (the #649 observed-spent guard then blocks re-insertion), which cannot be relied on.

PR #851 briefly carried an un-gated remove_spent_from_accounts pass that scanned all funding accounts per block transaction. It was dropped from the final PR to keep it scoped to #649. A proper fix should decide where this belongs:

  • an un-gated spend reconciliation pass (the dropped approach, reinstated deliberately with its own tests and cost analysis), or
  • widening get_relevant_account_types so any account type owning a spent input is always checked, or
  • an outpoint-indexed lookup (wallet-level OutPoint -> account index) making the reconciliation O(inputs) instead of O(inputs x accounts).

References

Activity

  1. xdustinface commented on Jul 16, 2026

    @xdustinface
    CollaboratorAuthor

    Closing: filed against a stale reading of the tree. The reported bug is already fixed, and the residual gaps are not reachable.

    The headline case is fixed. #867 (93b7477c8) widened the AssetLock arm of get_relevant_account_types to the full fund-bearing set plus the identity types, driven by the same class of production reports (dashpay/platform#4073, #4074, dashpay/dash-wallet#1507). It is pinned by test_asset_lock_spending_coinjoin_utxo_is_debited in wallet_checker.rs, which funds a CoinJoin UTXO, spends it via an AssetLock transaction with BIP44 change, asserts the classification really is AssetLock, and asserts the CoinJoin input is debited. So the exact scenario this issue describes is covered.

    The remaining arms cannot produce a stale UTXO. Auditing every classification against fund_bearing_account_types() = {StandardBIP44, StandardBIP32, CoinJoin, DashpayReceivingFunds, DashpayExternalAccount}:

    Classification Fund-bearing types omitted Reachable spend gap?
    Standard, CoinJoin none no
    AssetLock none no (#867)
    ProviderRegistration / ProviderUpdateRegistrar / ProviderUpdateService / ProviderUpdateRevocation both DashPay in principle only
    AssetUnlock CoinJoin, both DashPay no, cannot have inputs
    Coinbase CoinJoin, both DashPay no, no real prevouts
    Ignored all no, no inputs

    AssetUnlock is consensus-forbidden from carrying any input at all: Dash Core rejects such a transaction outright at src/evo/assetlocktx.cpp:150 (if (!tx.vin.empty()) return state.Invalid(..., "bad-assetunlocktx-have-input")). Its funds come from the credit pool, so there is no input to route and no coin to strand. Coinbase and Ignored likewise have no real prevouts.

    That leaves Provider* omitting the two DashPay account types. The mechanism is live (DashPay funds accounts are real, their coins count toward the balance and are offered as spendable, and TransactionBuilder::set_funding accepts any ManagedCoreFundsAccount), but it needs a masternode operator to deliberately fund a ProRegTx or ProUpServTx from a DIP15 contact-receiving account — money a contact sent them — while ignoring their standard accounts. Nothing in the tree does this and it is not a natural user action. Theoretically reachable, practically not.

    What came out of this that is worth keeping is a different bug with the opposite sign, on the credit side rather than the debit side: Coinbase and AssetUnlock consult only StandardBIP44/BIP32, so an output paying a CoinJoin or DashPay address is never discovered, undercounting the balance. Filed separately.

    Also worth noting for whoever touches this next: "fund-bearing account" is currently spelled out three times by hand — fund_bearing_account_types(), ManagedAccountCollection::all_funding_accounts(), and update_utxos's match arm. Drift between those lists is exactly what this issue thought it had found.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions