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:
- 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.
confirmed / spendable means what it says — not funds that can still be dropped from the mempool or that the network never accepted.
- 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:262 → CoinSelector::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-83 — is_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:492 — update_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 InBlock → Mempool, 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.
Addresses — addr/{a} return balance 0, txApperances 0:
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 @ 5c0113e7901551450f6063023eec4be95beeb6b9 — U1, U2.
dash-spv (same rust-dashcore workspace) — U3, src/client/transactions.rs.
Summary
Identity registration / top-up funded from a Core wallet balance intermittently dies on a ~300 s
AssetLockFinalityTimeoutinwait_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 returnsbad-txns-inputs-missingorspent(code -25) (see on-chain evidence below).Three independent
key-wallet/dash-spvdefects combine:broadcast_transactionre-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.Utxo::is_trustedis a flathas_owned_input && change-addrcheck 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 ofis_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 onkey-wallet) to fund identity operations from a Core wallet, I needkey-walletto never build an asset lock on non-final funds and to not surface non-final funds as confirmed/spendable, so that:wait_for_prooftimes out at 300 s. Identity register and top-up sit on this critical path.confirmed/spendablemeans what it says — not funds that can still be dropped from the mempool or that the network never accepted.Root cause — three defects
U2 — asset-lock coin selection admits 0-conf UTXOs (the direct timeout cause)
rs-platform-walletperforms no coin selection — for a wallet-balance-funded asset lock it hands only anaccount_indextokey-wallet:rs-platform-wallet/src/wallet/asset_lock/build.rs:93-101— the only handoff:Selection then happens entirely inside
key-wallet, over all account UTXOs, filtered only byis_spendable:key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs:79-80.../transaction_builder.rs:262→CoinSelector::select_coins_with_size(self.inputs.iter(), …), andkey-wallet/src/.../coin_selection.rs:146keeps a UTXO wheneveru.is_spendable(current_height).key-wallet/src/utxo.rs:78-83—is_spendablereturnstruefor mempool 0-conf UTXOs; its own doc (:74-77) states callers wanting confirmed/IS-locked funds "should checkis_confirmed || is_instantlockedthemselves." 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 defaultset_funding(it is shared with ordinary spends that may legitimately want 0-conf). Suggested: an opt-inrequire_finalitythreaded intoCoinSelectorattransaction_builder.rs:80, wired frombuild_asset_lock_with_signer(asset_lock_builder.rs:285). The one-line filter: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_transactionre-dispatches the same tx into the local pipeline as if it were an inbound network tx:There is no acceptance check and no BIP61
Rejecthandling (NetworkMessage::Rejectis 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 withis_confirmed == falseandis_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_trustedignores 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:.../managed_core_funds_account.rs:492—update_balancecreditsis_trustedinto the confirmed bucket:key-wallet/src/utxo.rs— theis_trustedfield doc claims Bitcoin CoreCWalletTx::IsTrustedparity, 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_trustedrecursive over ancestors (trusted only if every spent parent resolves tois_confirmed || is_instantlocked || is_trusted, and the tx is in our mempool), and/or stop ORingis_trustedintoconfirmedwhen a parent is non-final. The deterministic repro below pins this contract.Deterministic repro — U1
is_trustedcontract (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 contextInBlock→Mempool, and (b) the assertions to the documented Core-IsTrustedcontract. RED at5c0113e.Run:
cargo test -p key-wallet test_self_send_change_with_unconfirmed_parent_is_not_trusted -- --nocaptureOutput at
5c0113e: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 intokey-wallet/src/transaction_checking/wallet_checker.rs'stestsmodule to reproduce.End-to-end repro via dash-evo-tool (network + funded wallet)
The production symptom (300 s
AssetLockFinalityTimeout) reproduces through the full stack:(
dash-evo-tooltests/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}returnNot found:1bbb4929…8e71Not found— never propagatedd6f9492c…ba3a1bbb4929…Not foundb2b7a424…4715d6f9492c…:1— the change of the stuck lockNot foundThe 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.Addresses —
addr/{a}returnbalance 0, txApperances 0:yecwMxUCMdF1RcX2awcpkyAd6bqAa4coiNbalance 0, totalReceived 0, txApperances 0, transactions: []yUXYLK5bF9JGJcpx965gZLnbF71Lf9yhyrbalance 0, never seenReproducible today:
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 flatis_trustedcredits to the confirmed bucket.Coordinated fix (all in this repo; platform untouched)
key-walletis_confirmed || is_instantlocked(confirmed-only path)transaction_builder.rs:80,asset_lock_builder.rs:285key-walletis_trustedrecursive over ancestor finality; stop crediting non-final funds toconfirmedmanaged_core_funds_account.rs:193, 492dash-spvReject; reconcile on broadcast failureclient/transactions.rsrs-platform-walletneeds no change — it has no coin selector to gate; it only passes anaccount_indextobuild_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
is_trusted→ confirmed behaviour (origin of U1; the reviewer flagged the concern at the time).TransactionBuilder::set_fundingmutates funds account beforebuild_signedcan fail — phantommonitor_revisionbumps on failed builds #764 (Found-022), Found-021:TransactionRecord::update_contextsilently dropsInstantLockonInBlock/InChainLockedBlockpromotion #763 (Found-021) — adjacentkey-wallet/ asset-lock-path defects.dashpay/platform#3641 (Found-008, fixed) and #3642 (Found-012) are differentFinalityTimeoutcauses — this one is distinct (funding inputs never final). Please don't dup onto them.Affected revisions
key-wallet0.43.0 @5c0113e7901551450f6063023eec4be95beeb6b9— U1, U2.dash-spv(samerust-dashcoreworkspace) — U3,src/client/transactions.rs.