Skip to content

Asset-lock FinalityTimeout root cause: key-wallet builds asset locks on non-final (0-conf) funds — U2 (coin selection admits 0-conf, direct cause), U1 (is_trusted ignores ancestor finality), U3 (dash-spv dispatch_local injects un-accepted self-broadcast) #832

Description

@Claudius-Maginificent

Updated 2026-07-01 after an end-to-end trace through key-wallet + rs-platform-wallet + dash-spv. The original report correctly identified is_trusted (now labelled U1) but attributed the 300 s AssetLockFinalityTimeout to it. The trace shows the direct cause is a distinct defect — asset-lock coin selection admits 0-conf UTXOs (U2) — with the phantom funds originating in dash-spv (U3). All three defects are in this repo; no rs-platform-wallet / platform change is needed.

Summary

Identity registration / top-up funded from a Core wallet balance intermittently dies on a ~300 s AssetLockFinalityTimeout in wait_for_proof. The asset-lock funding transaction spends a phantom UTXO — the change of a prior funding tx that never propagated to the network — so it can never get an InstantSend lock or ChainLock, and the wait times out. A captured raw tx submitted to testnet Insight returns bad-txns-inputs-missingorspent (code -25) (see on-chain evidence below).

Three independent key-wallet / dash-spv defects combine:

  • U2 — direct cause (key-wallet). The asset-lock builder selects funding UTXOs with no finality filter — it admits 0-conf mempool UTXOs. So it will happily build an asset lock on a non-final input.
  • U3 — phantom origin (dash-spv). broadcast_transaction re-injects the wallet's own just-broadcast tx into the local UTXO set as a spendable mempool UTXO, with zero network-acceptance check and no BIP61 reject handling. If the broadcast is rejected or never propagates, the wallet keeps a phantom UTXO that only exists locally.
  • U1 — balance-display amplifier (key-wallet). Utxo::is_trusted is a flat has_owned_input && change-addr check with no ancestor-finality recursion, so those phantom self-send change outputs are credited to the confirmed / spendable display bucket — making the wallet look funded and feeding coin selection more phantoms.

Fixing U1 alone does not stop the timeout. Coin selection keys off is_spendable (which admits 0-conf), not the confirmed display bucket — so the selector still grabs the phantom regardless of is_trusted. The finality gate must be added at the selector itself (U2). U1 and U3 remain real and are required for a durable fix (correct balance + phantom eviction).

User Story

As a Dash Platform application developer using rs-platform-wallet (which depends on key-wallet) to fund identity operations from a Core wallet, I need key-wallet to never build an asset lock on non-final funds and to not surface non-final funds as confirmed/spendable, so that:

  1. Asset-lock funding uses only final coins. An asset lock built on a 0-conf / non-final input produces a funding tx that never IS-locks or ChainLocks → wait_for_proof times out at 300 s. Identity register and top-up sit on this critical path.
  2. confirmed / spendable means what it says — not funds that can still be dropped from the mempool or that the network never accepted.
  3. The wallet does not credit its own un-accepted broadcasts as spendable with zero confirmation.

Root cause — three defects

U2 — asset-lock coin selection admits 0-conf UTXOs (the direct timeout cause)

rs-platform-wallet performs no coin selection — for a wallet-balance-funded asset lock it hands only an account_index to key-wallet:

rs-platform-wallet/src/wallet/asset_lock/build.rs:93-101 — the only handoff:

info.core_wallet.build_asset_lock_with_signer(wallet, account_index, vec![funding], DEFAULT_FEE_PER_KB, signer)

Selection then happens entirely inside key-wallet, over all account UTXOs, filtered only by is_spendable:

key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs:79-80

self.inputs = funds_acc.utxos.values().cloned().collect();   // ALL utxos, no finality filter

.../transaction_builder.rs:262CoinSelector::select_coins_with_size(self.inputs.iter(), …), and key-wallet/src/.../coin_selection.rs:146 keeps a UTXO whenever u.is_spendable(current_height).

key-wallet/src/utxo.rs:78-83is_spendable returns true for mempool 0-conf UTXOs; its own doc (:74-77) states callers wanting confirmed/IS-locked funds "should check is_confirmed || is_instantlocked themselves." The asset-lock path never does, so an asset lock can be built on a 0-conf input.

Fix (U2): gate the asset-lock builder's candidate set on real finality — is_confirmed || is_instantlocked — as an asset-lock-specific confirmed-only path. Do not change the default set_funding (it is shared with ordinary spends that may legitimately want 0-conf). Suggested: an opt-in require_finality threaded into CoinSelector at transaction_builder.rs:80, wired from build_asset_lock_with_signer (asset_lock_builder.rs:285). The one-line filter:

self.inputs = funds_acc.utxos.values().filter(|u| u.is_confirmed || u.is_instantlocked).cloned().collect();

This is the fix that stops the FinalityTimeout. It needs its own regression test: build an asset lock from an account whose only funds are a 0-conf UTXO and assert the builder refuses / selects nothing, rather than producing a lock on a non-final input.

U3 — dash-spv injects the wallet's own un-accepted broadcast as a spendable UTXO (phantom origin)

dash-spv/src/client/transactions.rs — immediately after broadcasting, broadcast_transaction re-dispatches the same tx into the local pipeline as if it were an inbound network tx:

network_guard.broadcast(NetworkMessage::Tx(tx.clone())).await?;
network_guard.dispatch_local(NetworkMessage::Tx(tx.clone())).await;   // optimistic local credit

There is no acceptance check and no BIP61 Reject handling (NetworkMessage::Reject is defined but never processed). If the broadcast is rejected (e.g. it double-spends) or never propagates, the wallet still ingests the tx as a spendable mempool UTXO that exists only locally — the phantom. Its outputs are applied with is_confirmed == false and is_instantlocked == false (key-wallet/src/managed_account/managed_core_funds_account.rs:201-204), so a U2 finality gate would correctly exclude them.

Fix (U3): do not credit un-accepted self-broadcasts as trusted/spendable; handle NetworkMessage::Reject, and on broadcast rejection / parent-tx failure evict the phantom (reconcile the local UTXO set) instead of leaving it selectable.

U1 — Utxo::is_trusted ignores ancestor finality (inflates the confirmed display bucket)

key-wallet/src/managed_account/managed_core_funds_account.rs:193 — trust flag set with no ancestor-finality check:

let is_trusted_output = has_owned_input && change_addrs.contains(&addr);

.../managed_core_funds_account.rs:492update_balance credits is_trusted into the confirmed bucket:

} else if utxo.is_confirmed || utxo.is_instantlocked || utxo.is_trusted {
    confirmed += value;

key-wallet/src/utxo.rs — the is_trusted field doc claims Bitcoin Core CWalletTx::IsTrusted parity, which is recursive: a 0-conf output is trusted only if it is in our mempool and every parent is itself trusted. The flat setter diverges → doc ≠ behaviour. Effect: a phantom self-send change of an unconfirmed parent is displayed as confirmed/spendable, so the wallet looks funded and coin selection is fed more phantoms.

Fix (U1): make is_trusted recursive over ancestors (trusted only if every spent parent resolves to is_confirmed || is_instantlocked || is_trusted, and the tx is in our mempool), and/or stop ORing is_trusted into confirmed when a parent is non-final. The deterministic repro below pins this contract.

How they interact: U2 is necessary and sufficient to stop the FinalityTimeout recurrence. But the gate alone does not evict a phantom already injected by U3 — it keeps inflating the confirmed bucket (U1) and stays selectable for ordinary spends. A durable fix needs all three.

Deterministic repro — U1 is_trusted contract (hermetic unit test, no network)

A clone of the maintainers' own test_self_send_change_in_mempool_lands_in_confirmed_balance, changing only (a) the funding parent's context InBlockMempool, and (b) the assertions to the documented Core-IsTrusted contract. RED at 5c0113e.

/// Regression: a self-send whose spent input is itself an UNCONFIRMED
/// (mempool) parent must NOT be treated as trusted. `Utxo::is_trusted`'s
/// doc says it "Mirrors Bitcoin Core's `CWalletTx::IsTrusted`", and Core's
/// `IsTrusted` is recursive — a 0-conf output is trusted only if every
/// parent is itself trusted (and in our mempool). The current flat
/// `has_owned_input && change-addr` check ignores ancestor finality, so it
/// credits non-final funds to the confirmed/spendable bucket. Downstream
/// (rs-platform-wallet) then builds asset locks on those funds; the funding
/// tx never IS-locks or ChainLocks and the flow dies on a 300s
/// `FinalityTimeout`.
///
/// This asserts the DOCUMENTED contract and is RED at 5c0113e (the flat
/// check sets `is_trusted == true` and credits `confirmed == change_amount`).
/// Only two things differ from the sibling test above: the funding parent's
/// context (`InBlock` -> `Mempool`) and the assertions (which encode the
/// documented Core-`IsTrusted` behaviour rather than the current one).
#[tokio::test]
async fn test_self_send_change_with_unconfirmed_parent_is_not_trusted() {
    let mut ctx = TestWalletContext::new_random();
    let external_address = Address::p2pkh(
        &dashcore::PublicKey::from_slice(&[0x02; 33]).expect("pubkey"),
        Network::Testnet,
    );

    // UNCONFIRMED funding UTXO (mempool parent) — this is the ONLY setup
    // difference from `test_self_send_change_in_mempool_lands_in_confirmed_balance`,
    // which funds from a confirmed `InBlock` parent.
    let funding_value = 1_000_000u64;
    let funding_tx = Transaction::dummy(&ctx.receive_address, 0..1, &[funding_value]);
    ctx.check_transaction(&funding_tx, TransactionContext::Mempool).await;
    // The unconfirmed parent is external (we own no input) -> unconfirmed bucket.
    assert_eq!(ctx.managed_wallet.balance.confirmed(), 0);
    assert_eq!(ctx.managed_wallet.balance.unconfirmed(), funding_value);

    let change_address = ctx
        .managed_wallet
        .first_bip44_managed_account_mut()
        .expect("account")
        .next_change_address(Some(&ctx.xpub), true)
        .expect("change address");

    // Spend the still-unconfirmed funding UTXO: some out, the rest back to
    // ourselves as change. Broadcast into the mempool.
    let send_amount = 600_000u64;
    let fee = 1_000u64;
    let change_amount = funding_value - send_amount - fee;
    let spend_tx = Transaction {
        version: 2,
        lock_time: 0,
        input: vec![TxIn {
            previous_output: OutPoint { txid: funding_tx.txid(), vout: 0 },
            script_sig: ScriptBuf::new(),
            sequence: 0xffffffff,
            witness: dashcore::Witness::new(),
        }],
        output: vec![
            TxOut { value: send_amount, script_pubkey: external_address.script_pubkey() },
            TxOut { value: change_amount, script_pubkey: change_address.script_pubkey() },
        ],
        special_transaction_payload: None,
    };
    ctx.check_transaction(&spend_tx, TransactionContext::Mempool).await;

    let change_outpoint = OutPoint { txid: spend_tx.txid(), vout: 1 };
    let change_utxo =
        ctx.bip44_account().utxos.get(&change_outpoint).expect("change UTXO recorded");

    // DOCUMENTED CONTRACT (Core `IsTrusted` is recursive): the parent is
    // unconfirmed, so the change is NOT trusted and stays unconfirmed.
    assert!(
        !change_utxo.is_trusted,
        "change spending an UNCONFIRMED parent must not be trusted (CWalletTx::IsTrusted is recursive)"
    );
    assert_eq!(
        ctx.managed_wallet.balance.confirmed(),
        0,
        "non-final funds must not be counted as confirmed/spendable"
    );
    assert_eq!(ctx.managed_wallet.balance.unconfirmed(), change_amount);
}

Run:

cargo test -p key-wallet test_self_send_change_with_unconfirmed_parent_is_not_trusted -- --nocapture

Output at 5c0113e:

thread '...test_self_send_change_with_unconfirmed_parent_is_not_trusted' panicked at
key-wallet/src/transaction_checking/wallet_checker.rs:
  change spending an UNCONFIRMED parent must not be trusted (CWalletTx::IsTrusted is recursive)
test result: FAILED. 0 passed; 1 failed; 513 filtered out

The funding-side assertions (external mempool parent stays unconfirmed) pass; the failure is precisely on the self-send change being wrongly trusted — isolating the defect. Paste the test into key-wallet/src/transaction_checking/wallet_checker.rs's tests module to reproduce.

End-to-end repro via dash-evo-tool (network + funded wallet)

The production symptom (300 s AssetLockFinalityTimeout) reproduces through the full stack:

E2E_WALLET_MNEMONIC="<funded testnet seed, >= 0.6 tDASH>" \
  cargo test --test backend-e2e --all-features -- \
  --ignored --nocapture cd_cold_boot_identity_register_and_topup

(dash-evo-tool tests/backend-e2e/identity_cold_boot.rs; requires testnet egress + a framework wallet funded with ≥ 0.6 tDASH. Fund the wallet with a self-send first to make the change-based reproduction reliable.) This path is network- and timing-dependent; the unit test above is the deterministic form.

On-chain evidence (testnet Insight)

From an actual reproduction (identity registration funded from Wallet Balance). Insight instance: https://insight.testnet.networks.dash.org/insight-api. Every transaction in the funding chain is "Not found" (never entered the network), and the change address the wallet displays as "Confirmed 3.99999406 DASH" has never been seen on-chain — the textbook phantom-confirmed balance this bug produces.

Transactions — all tx/{txid} return Not found:

Tx Role Input Insight
1bbb4929…8e71 ROOT funding tx (~5 DASH into the wallet) Not found — never propagated
d6f9492c…ba3a original asset-lock (0.5 DASH credit) 1bbb4929… Not found
b2b7a424…4715 retry asset-lock (Wallet Balance retry) d6f9492c…:1 — the change of the stuck lock Not found

The retry (b2b7…) is this bug in the wild: it funds a new asset lock from the change output of a tx that itself never confirmed (d6f9492c…:1). The wallet treated that change as trusted/"confirmed" and spent it.

Addressesaddr/{a} return balance 0, txApperances 0:

Address Role Insight (live)
yecwMxUCMdF1RcX2awcpkyAd6bqAa4coiN holds the wallet's "Confirmed 3.99999406 DASH" (change of the asset-lock txs) balance 0, totalReceived 0, txApperances 0, transactions: []
yUXYLK5bF9JGJcpx965gZLnbF71Lf9yhyr asset-lock credit output balance 0, never seen

Reproducible today:

curl -s https://insight.testnet.networks.dash.org/insight-api/addr/yecwMxUCMdF1RcX2awcpkyAd6bqAa4coiN
# {"addrStr":"yecwM…","balance":0,...,"txApperances":0,"transactions":[]}
curl -s https://insight.testnet.networks.dash.org/insight-api/tx/b2b7a4244811ec68a4e0ff8c0098ad1e25cdf4732423490cb76238bd92784715
# Not found

DET's wallet showed ~4 DASH "Confirmed" at yecwM…, built asset locks on it, and none of it ever existed on-chain — precisely the non-final funds the flat is_trusted credits to the confirmed bucket.

Coordinated fix (all in this repo; platform untouched)

Defect Repo / crate Stops the timeout? Fix Site
U2 key-wallet yes — the ship-first fix asset-lock builder gates funding on is_confirmed || is_instantlocked (confirmed-only path) transaction_builder.rs:80, asset_lock_builder.rs:285
U1 key-wallet no — fixes balance display + slows phantom growth is_trusted recursive over ancestor finality; stop crediting non-final funds to confirmed managed_core_funds_account.rs:193, 492
U3 dash-spv no — evicts phantoms at the source don't credit un-accepted self-broadcasts; handle Reject; reconcile on broadcast failure client/transactions.rs

rs-platform-wallet needs no change — it has no coin selector to gate; it only passes an account_index to build_asset_lock_with_signer. (An earlier draft of this issue suggested a platform-side finality gate; the trace above shows that is not possible.)

Related issues

Affected revisions

  • key-wallet 0.43.0 @ 5c0113e7901551450f6063023eec4be95beeb6b9U1, U2.
  • dash-spv (same rust-dashcore workspace) — U3, src/client/transactions.rs.

Activity

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