From 742ffc3d035bbd691dc10f821f97e894ec67c77d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 7 May 2026 09:33:12 +0200 Subject: [PATCH 1/5] =?UTF-8?q?feat(rs-platform-wallet):=20CR-004=20?= =?UTF-8?q?=E2=80=94=20legacy=20BIP32=20UTXO=20update=20after=20spend=20(d?= =?UTF-8?q?ash-evo-tool#845)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin the post-broadcast UTXO-mutation contract on standard_bip32_accounts end-to-end (TEST_SPEC.md → CR-004) and annotate the production call site so a regression in the routing layer lands at a named breadcrumb, not silently in dash-evo-tool's issue tracker. Test (FAILING-by-design until SPV runtime gates clear and the harness exposes a stable BIP32 receive-address derivation point): - Funds the legacy BIP32 account 0 with two distinct UTXOs via the existing bank.send_core_to helper (no harness refactor — derives the BIP32 receive address inline by mirroring the BIP-44 helper shape on standard_bip32_accounts). - Asserts no cross-account contamination: BIP-44 stays empty, BIP-32 ends up at exactly 2 spendable UTXOs before the broadcast. - Sweeps the legacy account via send_to_addresses(BIP32Account, 0, ...). - POST-pin: standard_bip32_accounts[0].spendable_utxos == empty AND a follow-up send fails with TransactionBuild naming an empty input set (NOT Ok(...) — the bug surface from dash-evo-tool#845 is exactly the Ok-with-phantom-UTXOs case). Production breadcrumb (no behavior change): - broadcast.rs:185 (the post-broadcast check_core_transaction(Mempool) hook) gains a TODO(CR-004) docstring naming the BIP32-routing contract and a tracing::debug! that surfaces the affected account_type + tx outpoint counts. The downstream routing in key_wallet::TransactionRouter::get_relevant_account_types currently DOES include StandardBIP32 for TransactionType::Standard at the pinned revision; the breadcrumb makes a future drop-out visible at a single grep-able call site. Verification: - cargo check -p platform-wallet --tests ok - cargo clippy -p platform-wallet --tests --all-features -- -D warnings clean - cargo fmt --all -- --check clean - cargo test -p platform-wallet --lib 149 passed Refs: dashpay/dash-evo-tool#845, TEST_SPEC.md → CR-004 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/wallet/core/broadcast.rs | 22 + ...04_legacy_bip32_utxo_update_after_spend.rs | 419 ++++++++++++++++++ .../rs-platform-wallet/tests/e2e/cases/mod.rs | 1 + 3 files changed, 442 insertions(+) create mode 100644 packages/rs-platform-wallet/tests/e2e/cases/cr_004_legacy_bip32_utxo_update_after_spend.rs diff --git a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index f3c9f0ae52..f610083679 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -173,6 +173,18 @@ impl CoreWallet { // network resolves that race exactly as it does on `v3.1-dev` // today, but neither caller corrupts local state on a transient // broadcast failure. + // + // TODO(CR-004 / dash-evo-tool#845): pin that this post-broadcast + // hook actually mutates `standard_bip32_accounts` UTXO state — not + // just `standard_bip44_accounts`. The downstream + // `key_wallet::ManagedWalletInfo::check_core_transaction` routes + // standard txs through both BIP32 and BIP44 collections via + // `TransactionRouter::get_relevant_account_types`, but if a future + // routing regression skips BIP32 (the "legacy" path DET v0.9.x + // wallets still rely on), this call will silently leave UTXOs + // marked spendable on `standard_bip32_accounts[0]`. The CR-004 e2e + // test pins the contract end-to-end; this comment names the call + // site so a reviewer chasing that test failure lands here directly. { let mut wm = self.wallet_manager.write().await; let (wallet, info) = @@ -182,6 +194,16 @@ impl CoreWallet { "Wallet not found in wallet manager".to_string(), ) })?; + tracing::debug!( + target: "platform_wallet::core::broadcast", + txid = %tx.txid(), + account_type = ?account_type, + account_index, + inputs = tx.input.len(), + outputs = tx.output.len(), + "post-broadcast: dispatching check_core_transaction(Mempool) — \ + must mark consumed UTXOs spent on the matching account collection" + ); info.check_core_transaction(&tx, TransactionContext::Mempool, wallet, true, true) .await; } diff --git a/packages/rs-platform-wallet/tests/e2e/cases/cr_004_legacy_bip32_utxo_update_after_spend.rs b/packages/rs-platform-wallet/tests/e2e/cases/cr_004_legacy_bip32_utxo_update_after_spend.rs new file mode 100644 index 0000000000..5886f3e624 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/cr_004_legacy_bip32_utxo_update_after_spend.rs @@ -0,0 +1,419 @@ +//! CR-004 — Legacy BIP32 account: balance + UTXO state updates after spend. +//! +//! Spec: `tests/e2e/TEST_SPEC.md` (### Core (CR) → CR-004). +//! Pinned status: FAILING-by-design — the canonical contract for the +//! post-broadcast UTXO mutation on `standard_bip32_accounts` is asserted +//! end-to-end. Mirrors the bug surfaced by +//! [dashpay/dash-evo-tool#845](https://github.com/dashpay/dash-evo-tool/issues/845): +//! after a "send all" spend on the legacy BIP32 account, the wallet's +//! local UTXO set is left stale and a follow-up +//! [`CoreWallet::send_to_addresses`] fails with a coin-selection error +//! ("No UTXOs available for selection") instead of a clean +//! insufficient-funds path. +//! +//! Why FAILING-by-design and not WIRED: +//! 1. The bug ships from a downstream consumer (DET v0.9.x). The test +//! pins the rs-platform-wallet contract so a future routing +//! regression in `key_wallet::ManagedWalletInfo::check_core_transaction` +//! that drops `StandardBIP32` from the standard-tx routing table is +//! caught here, not in dash-evo-tool's issue tracker. +//! 2. The production fix lives behind the `wallet/core/broadcast.rs:185` +//! `check_core_transaction(Mempool, ...)` call site (now annotated with +//! a `TODO(CR-004)` breadcrumb). The downstream router currently DOES +//! iterate BIP32 (see `key_wallet::transaction_checking::transaction_router` +//! at the pinned revision — `StandardBIP32` is in +//! `get_relevant_account_types(TransactionType::Standard)`), but the +//! test exercises the contract end-to-end so any silent regression is +//! visible the moment a CI run flips status. +//! 3. The harness does NOT yet expose +//! `CoreWallet::next_receive_address_for_bip32_account` (the BIP-44 +//! sibling on `wallet/core/wallet.rs:59` has no BIP-32 counterpart); +//! the helper this test reaches for is implemented INLINE here. When +//! a parallel branch lifts that into the public surface, the inline +//! helper collapses to a one-line call. + +use std::time::Duration; + +use dashcore::Address as DashAddress; +use key_wallet::account::account_type::StandardAccountType; +use platform_wallet::PlatformWalletError; + +use crate::framework::prelude::*; +use crate::framework::wait::wait_for_core_balance; + +/// Per-UTXO funding amount in duffs. Two distinct UTXOs land on the +/// legacy BIP32 account so coin selection has more than one input to +/// consider — matching the SPEC's "build a 'send all' transfer that +/// consumes every UTXO" requirement (without ≥ 2 UTXOs the contract +/// degenerates to "single-input spend", which doesn't exercise the +/// "send all" semantics). +const PER_UTXO_FUNDING: u64 = 50_000_000; // 0.5 DASH testnet + +/// Total funding the bank delivers across two `send_core_to` calls. +/// Sized so the bank's `confirmed >= TOTAL + CORE_TX_FEE_RESERVE` gate +/// clears with the same pre-funding floor used by CR-003. +const TOTAL_FUNDING: u64 = PER_UTXO_FUNDING * 2; + +/// Deadline for each post-broadcast wait. Matches CR-003's +/// `CORE_FUNDING_TIMEOUT` so cold-cache SPV scans don't false-fail. +const CORE_BALANCE_TIMEOUT: Duration = Duration::from_secs(300); + +/// Small Core transfer amount used in step 5 — the second send-attempt +/// after the legacy account has been drained. The exact number doesn't +/// matter; what matters is that coin selection is invoked on a known-empty +/// UTXO set and surfaces a clean failure (NOT an unrelated "select-failed +/// on stale UTXO" error path). +const POST_DRAIN_PROBE_AMOUNT: u64 = 1_000_000; + +#[ignore = "CR-004 — FAILING-by-design until SPV runtime gates clear AND the \ + harness exposes a stable BIP32-receive-address derivation point. \ + Pins the post-broadcast UTXO-mutation contract on \ + `standard_bip32_accounts` (dash-evo-tool#845). Requires testnet \ + + bank Core (Layer-1) pre-funding (TOTAL_FUNDING duffs + per-tx \ + fee reserve, twice — once per UTXO). The legacy BIP32 account \ + derivation must NOT cross-contaminate the wallet's default \ + BIP-44 Core account UTXO set; assertions read \ + `standard_bip32_accounts[0]` directly."] +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn cr_004_legacy_bip32_utxo_update_after_spend() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + // Step 1: bring up a fresh test wallet. `WalletAccountCreationOptions::Default` + // creates BOTH `standard_bip44_accounts[0]` AND `standard_bip32_accounts[0]` + // (see `key_wallet::wallet::initialization::WalletAccountCreationOptions::Default` + // doc), so the BIP32 collection is already populated on `setup`. + let s = crate::framework::setup() + .await + .expect("setup (CR-004 — fresh-seeded test wallet with default account set)"); + + // Step 2: derive the legacy BIP32 account 0 receive address INLINE. + // `CoreWallet` has no `next_receive_address_for_bip32_account` helper + // today (see module docstring); we mirror the BIP-44 helper's shape + // (`packages/rs-platform-wallet/src/wallet/core/wallet.rs:59`) directly + // against `standard_bip32_accounts`. + let bip32_recv_1 = next_receive_address_for_bip32_account(&s.test_wallet, 0) + .await + .expect("derive legacy BIP32 receive address (slot 1)"); + let bip32_recv_2 = next_receive_address_for_bip32_account(&s.test_wallet, 0) + .await + .expect("derive legacy BIP32 receive address (slot 2)"); + assert_ne!( + bip32_recv_1, bip32_recv_2, + "PRE-pin violated: BIP32 receive-address pool returned the same \ + address twice — pool advance is broken or marking-used dropped \ + the inbound funding event." + ); + + // Step 3: bank-fund the legacy account with TWO distinct UTXOs. + // Two `send_core_to` calls — one per receive-address slot — give us + // two outpoints on the same legacy account, so step 4's "send all" + // exercises true multi-UTXO coin selection (not a degenerate + // single-input shape). + let txid_1 = s + .ctx + .bank() + .send_core_to(&bip32_recv_1, PER_UTXO_FUNDING) + .await + .expect("bank.send_core_to (legacy BIP32 slot 1)"); + let txid_2 = s + .ctx + .bank() + .send_core_to(&bip32_recv_2, PER_UTXO_FUNDING) + .await + .expect("bank.send_core_to (legacy BIP32 slot 2)"); + tracing::info!( + target: "platform_wallet::e2e::cases::cr_004", + %txid_1, + %txid_2, + per_utxo = PER_UTXO_FUNDING, + total = TOTAL_FUNDING, + "CR-004: bank delivered two UTXOs to legacy BIP32 account 0" + ); + + // Step 4: wait for the SPV bloom filter to observe the inbound + // UTXOs. `core_balance_confirmed` aggregates across BIP-44 + BIP-32 + // accounts (see `wallet/core/balance_handler.rs:42` — the upstream + // `WalletCoreBalance` is wallet-aggregate, not per-account). The + // test wallet's BIP-44 account 0 is unfunded at this point, so any + // confirmed balance came from the BIP-32 sends. + let observed = wait_for_core_balance(&s.test_wallet, TOTAL_FUNDING, CORE_BALANCE_TIMEOUT) + .await + .expect("wait_for_core_balance (TOTAL_FUNDING on legacy BIP32 account)"); + assert!( + observed >= TOTAL_FUNDING, + "PRE-pin violated: wait_for_core_balance returned with \ + observed {observed} < TOTAL_FUNDING {TOTAL_FUNDING}" + ); + + // Step 4b: cross-account contamination check. The legacy account + // (BIP-32) must own the new UTXOs, NOT the wallet's default BIP-44 + // account 0. If the BIP-44 account is non-empty, the routing layer + // is mis-attributing inbound UTXOs and the rest of the test would + // pass for the wrong reason. + let (bip44_count_pre, bip32_count_pre) = utxo_counts(&s.test_wallet, 0).await; + assert_eq!( + bip44_count_pre, 0, + "PRE-pin violated: BIP-44 account 0 has {bip44_count_pre} UTXOs \ + after funding the BIP-32 account — cross-account contamination \ + would let the test pass for the wrong reason." + ); + assert_eq!( + bip32_count_pre, 2, + "PRE-pin violated: legacy BIP-32 account 0 has {bip32_count_pre} \ + UTXOs after the bank's two `send_core_to` calls — expected 2." + ); + + // Step 5: build a "send all" Core transfer via + // `CoreWallet::send_to_addresses(StandardAccountType::BIP32Account, 0, ...)`. + // `send_to_addresses` selects from the BIP-32 account's spendable + // set internally and sends change back to the same account; sending + // the FULL TOTAL_FUNDING to a fresh sink address forces selection + // to consume both UTXOs and emit a near-zero (or zero) change + // output, exercising the "send all" semantics the bug report + // names. + // + // The fee is taken from the consumed inputs, so the actual + // delivered amount lands slightly under TOTAL_FUNDING — that's + // fine, the contract under test is "the SOURCE account's UTXO set + // becomes empty after broadcast", not "the destination receives + // exactly N". We send to the bank's primary Core receive address + // so the swept duffs are recoverable on teardown failure. + let sink = s + .ctx + .bank() + .primary_core_receive_address() + .await + .expect("bank.primary_core_receive_address"); + let send_all = TOTAL_FUNDING.saturating_sub(50_000); // leave headroom for fee + let tx = s + .test_wallet + .platform_wallet() + .core() + .send_to_addresses( + StandardAccountType::BIP32Account, + 0, + vec![(sink.clone(), send_all)], + ) + .await + .expect( + "send_to_addresses(BIP32Account, 0, send_all) — the legacy \ + BIP32 broadcast path must succeed; failure here means the \ + broadcast itself is broken, not the post-broadcast state \ + update CR-004 actually pins.", + ); + tracing::info!( + target: "platform_wallet::e2e::cases::cr_004", + txid = %tx.txid(), + sink = %sink, + "CR-004: legacy BIP32 send-all broadcast" + ); + + // Step 6: assert the post-broadcast state mutation actually + // happened on `standard_bip32_accounts[0]`. The contract: + // + // - The mempool-context `check_core_transaction` call inside + // `send_to_addresses` (see `wallet/core/broadcast.rs:185`) must + // route the just-broadcast tx through the BIP-32 account + // collection AND mark every consumed UTXO as spent. + // - `spendable_utxos(current_height)` on the legacy account must + // return an empty set (or, at most, an unspent change output — + // but since we sent `TOTAL_FUNDING - 50_000` with fee deducted + // from inputs, the change is below the dust floor and the + // builder will have folded it into the fee, so we expect + // strictly empty here). + let (bip44_count_post, bip32_count_post) = utxo_counts(&s.test_wallet, 0).await; + assert_eq!( + bip44_count_post, 0, + "POST-pin violated: BIP-44 account 0 grew to {bip44_count_post} \ + UTXOs after a BIP-32 send-all — the broadcast or its + post-broadcast hook is mis-attributing the change output." + ); + assert_eq!( + bip32_count_post, 0, + "POST-pin violated (dash-evo-tool#845): legacy BIP-32 account \ + 0 has {bip32_count_post} spendable UTXOs after a `send_to_addresses` \ + broadcast that consumed both inputs. The post-broadcast \ + `check_core_transaction(Mempool, ...)` call at \ + `wallet/core/broadcast.rs:185` must have marked the consumed \ + UTXOs spent on `standard_bip32_accounts[0]`. If this fires, \ + either (a) the routing in \ + `key_wallet::transaction_checking::transaction_router::TransactionRouter::get_relevant_account_types(TransactionType::Standard)` \ + dropped `StandardBIP32` (regression — the legacy DET 0.9.x \ + path), or (b) the post-broadcast hook is being invoked but \ + the `account_type_match` for BIP-32 has lost its mark-spent \ + side effect." + ); + + // Step 7: re-attempt a Core transfer on the now-drained legacy + // account. The bug surface in DET#845 is "this fails with a + // coin-selection error pretending UTXOs exist"; the fix is for + // it to fail cleanly with a build-stage error that names the + // empty input set. We pin the looser contract: `Err(_)` AND the + // error message names "No UTXOs" / "no spendable inputs" / the + // word "selection" so a regression that returns `Ok(...)` (i.e. + // the wallet attempts to spend phantom UTXOs) flips the test + // immediately. + let probe = s + .test_wallet + .platform_wallet() + .core() + .send_to_addresses( + StandardAccountType::BIP32Account, + 0, + vec![(sink.clone(), POST_DRAIN_PROBE_AMOUNT)], + ) + .await; + match probe { + Err(PlatformWalletError::TransactionBuild(msg)) => { + assert!( + msg.to_lowercase().contains("no utxos") + || msg.to_lowercase().contains("no spendable") + || msg.to_lowercase().contains("coin selection") + || msg.to_lowercase().contains("insufficient"), + "POST-pin violated: second send_to_addresses on a drained \ + legacy BIP-32 account failed with TransactionBuild but \ + the message does NOT identify the empty-input cause: \ + {msg:?}. The contract requires a clean \ + 'no spendable inputs' / 'insufficient' / coin-selection \ + message." + ); + tracing::info!( + target: "platform_wallet::e2e::cases::cr_004", + msg, + "CR-004: post-drain second send failed cleanly (expected)" + ); + } + Err(other) => { + panic!( + "POST-pin violated: second send_to_addresses on a drained \ + legacy BIP-32 account failed with {other:?} — expected \ + PlatformWalletError::TransactionBuild naming the empty \ + input set." + ); + } + Ok(tx) => { + panic!( + "POST-pin violated (dash-evo-tool#845): second \ + send_to_addresses on a drained legacy BIP-32 account \ + RETURNED Ok with txid {} — the wallet selected phantom \ + UTXOs that the post-broadcast hook should have marked \ + spent. This is the exact buggy path the upstream issue \ + reports.", + tx.txid() + ); + } + } + + // Sanity assert the sink address is on the same network as the + // wallet — a network mismatch here would mean the send target was + // wrong all along and the earlier broadcast went somewhere + // unexpected. `key_wallet::Network` is a re-export of + // `dashcore::Network`, so a direct `==` works without casting. + assert_eq!( + *sink.network(), + s.ctx.config.network, + "PRE-pin violated: sink address network does not match test \ + wallet network; CR-004 sweep would broadcast to the wrong chain." + ); + + s.teardown().await.expect("teardown"); +} + +// --------------------------------------------------------------------------- +// Inline helpers (lift to `framework/` when CR-004 graduates from +// FAILING-by-design — see module docstring point 3). +// --------------------------------------------------------------------------- + +/// Derive the next unused receive address on the wallet's legacy BIP-32 +/// account at `account_index`. Mirror of +/// [`platform_wallet::wallet::core::CoreWallet::next_receive_address_for_account`] +/// (`packages/rs-platform-wallet/src/wallet/core/wallet.rs:59`) but +/// against `standard_bip32_accounts`. +async fn next_receive_address_for_bip32_account( + test_wallet: &crate::framework::wallet_factory::TestWallet, + account_index: u32, +) -> Result { + let wallet = test_wallet.platform_wallet(); + let mut wm = wallet.wallet_manager().write().await; + let wallet_id = wallet.wallet_id(); + let (kw, info) = wm.get_wallet_and_info_mut(&wallet_id).ok_or_else(|| { + PlatformWalletError::WalletNotFound(format!( + "wallet {} missing from manager during BIP-32 receive-address derive", + hex::encode(wallet_id) + )) + })?; + + let xpub = kw + .accounts + .standard_bip32_accounts + .get(&account_index) + .map(|a| a.account_xpub) + .ok_or_else(|| { + PlatformWalletError::WalletNotFound(format!( + "BIP-32 account {} not found in wallet — \ + WalletAccountCreationOptions::Default should have created it", + account_index + )) + })?; + + let account = info + .core_wallet + .accounts + .standard_bip32_accounts + .get_mut(&account_index) + .ok_or_else(|| { + PlatformWalletError::WalletNotFound(format!( + "BIP-32 managed account {} not found in wallet info", + account_index + )) + })?; + + account + .next_receive_address(Some(&xpub), true) + .map_err(|e| PlatformWalletError::AddressOperation(e.to_string())) +} + +/// Snapshot `(bip44_spendable_count, bip32_spendable_count)` at +/// account index `account_index`. Used as a cross-contamination check +/// (BIP-44 must stay empty when only BIP-32 is funded) and as the +/// post-broadcast assertion target (BIP-32 must drop to 0 after a +/// "send all"). Reads through the wallet manager write lock so the +/// snapshot is consistent with the synced height used inside +/// `send_to_addresses`. +async fn utxo_counts( + test_wallet: &crate::framework::wallet_factory::TestWallet, + account_index: u32, +) -> (usize, usize) { + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + + let wallet = test_wallet.platform_wallet(); + let wallet_id = wallet.wallet_id(); + let wm = wallet.wallet_manager().read().await; + let info = wm + .get_wallet_info(&wallet_id) + .expect("wallet present in manager"); + + let height = info.core_wallet.synced_height(); + + let bip44 = info + .core_wallet + .accounts + .standard_bip44_accounts + .get(&account_index) + .map(|a| a.spendable_utxos(height).len()) + .unwrap_or(0); + let bip32 = info + .core_wallet + .accounts + .standard_bip32_accounts + .get(&account_index) + .map(|a| a.spendable_utxos(height).len()) + .unwrap_or(0); + (bip44, bip32) +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/mod.rs b/packages/rs-platform-wallet/tests/e2e/cases/mod.rs index e316c2a97e..fca6cc64e0 100644 --- a/packages/rs-platform-wallet/tests/e2e/cases/mod.rs +++ b/packages/rs-platform-wallet/tests/e2e/cases/mod.rs @@ -7,6 +7,7 @@ //! in subsequent PRs. pub mod cr_003_asset_lock_funded_registration; +pub mod cr_004_legacy_bip32_utxo_update_after_spend; pub mod dpns_001_register_name; pub mod id_001_register_identity_from_addresses; pub mod id_002_top_up_identity; From 96cd1dbbaa7f488ef3ec4513c7a9fa771493a36d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 7 May 2026 10:12:13 +0200 Subject: [PATCH 2/5] feat(rs-platform-wallet/e2e): PLATFORM_WALLET_E2E_RUN_FAILING_BY_DESIGN env-var guard for CR-004 (QA-V18-003) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `#[ignore]` is bypassed by `cargo test -- --ignored`, which runs every ignored case — so CR-004's FAILING-by-design body still panicked and polluted the standard ignored-cohort run. Add a runtime opt-in guard at the top of the test body: unless `PLATFORM_WALLET_E2E_RUN_FAILING_BY_DESIGN` is truthy (`1`/`true`/`yes`/`on`, case-insensitive) the test early-returns as a passing no-op with an `eprintln!` breadcrumb. Operators who want to exercise the pinned regression set the var explicitly. Also declare the new env-var key in `framework::config::vars` next to `DISABLE_SPV` so the cohort of operator-facing knobs stays in one place; the existing `parse_truthy` helper handles the truthy parse so no new logic is needed. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...04_legacy_bip32_utxo_update_after_spend.rs | 18 ++++++++++++++ .../tests/e2e/framework/config.rs | 24 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/packages/rs-platform-wallet/tests/e2e/cases/cr_004_legacy_bip32_utxo_update_after_spend.rs b/packages/rs-platform-wallet/tests/e2e/cases/cr_004_legacy_bip32_utxo_update_after_spend.rs index 5886f3e624..b88d423097 100644 --- a/packages/rs-platform-wallet/tests/e2e/cases/cr_004_legacy_bip32_utxo_update_after_spend.rs +++ b/packages/rs-platform-wallet/tests/e2e/cases/cr_004_legacy_bip32_utxo_update_after_spend.rs @@ -76,6 +76,24 @@ const POST_DRAIN_PROBE_AMOUNT: u64 = 1_000_000; `standard_bip32_accounts[0]` directly."] #[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] async fn cr_004_legacy_bip32_utxo_update_after_spend() { + // FAILING-by-design guard: `#[ignore]` is bypassed by + // `cargo test -- --ignored`, which runs every ignored case. CR-004 + // is intentionally pinning a not-yet-reproducible upstream bug and + // would pollute the standard `--ignored` cohort with a body-side + // panic. Require an explicit opt-in env var so the case can still + // be exercised on demand without being part of the default run. + if !crate::framework::config::parse_truthy( + std::env::var(crate::framework::config::vars::RUN_FAILING_BY_DESIGN) + .ok() + .as_deref(), + ) { + eprintln!( + "CR-004 skipped: set {}=1 to exercise (FAILING-by-design pin per spec)", + crate::framework::config::vars::RUN_FAILING_BY_DESIGN + ); + return; + } + let _ = tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() diff --git a/packages/rs-platform-wallet/tests/e2e/framework/config.rs b/packages/rs-platform-wallet/tests/e2e/framework/config.rs index 903e789f9f..84a8fbe45d 100644 --- a/packages/rs-platform-wallet/tests/e2e/framework/config.rs +++ b/packages/rs-platform-wallet/tests/e2e/framework/config.rs @@ -52,6 +52,30 @@ pub mod vars { /// that don't need Core duffs; any positive integer overrides the /// timeout (in seconds). pub const BANK_CORE_GATE: &str = "PLATFORM_WALLET_E2E_BANK_CORE_GATE"; + /// Operator escape hatch: skip starting the SPV runtime and the + /// `wait_for_mn_list_synced` gate. Truthy values (`1` / `true` / + /// `yes` / `on`, case-insensitive) opt in. When set, Core-dependent + /// tests (CR-003 funded-asset-lock path, ID-007 Core-balance gates, + /// any helper that walks Core blocks) WILL fail; Platform-only + /// flows still run. Use this to keep the suite making progress when + /// testnet is in a ChainLock-cycle window that prevents mn-list + /// from advancing (rust-dashcore #470). + pub const DISABLE_SPV: &str = "PLATFORM_WALLET_E2E_DISABLE_SPV"; + /// Opt-in switch for FAILING-by-design tests that would otherwise + /// pollute a `cargo test -- --ignored` run with their pinned + /// failure (the `#[ignore]` attribute is bypassed by `--ignored`, + /// so a body-side guard is the only way to keep the standard + /// ignored-cohort run clean). + /// + /// Truthy values (`1` / `true` / `yes` / `on`, case-insensitive) + /// flip the guarded test bodies into "actually exercise the + /// pinned regression" mode; everything else (unset / empty / + /// falsy) makes them early-return as a passing no-op. + /// + /// Currently consumed by: + /// - CR-004 (`cr_004_legacy_bip32_utxo_update_after_spend`) — + /// pins dash-evo-tool#845's UTXO-update-after-spend regression. + pub const RUN_FAILING_BY_DESIGN: &str = "PLATFORM_WALLET_E2E_RUN_FAILING_BY_DESIGN"; } /// Default deadline for the bank Core funding gate when the env var is From 5a5d1ebcb4c868858274f2f2ea3ea40e57d47474 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 7 May 2026 14:19:14 +0200 Subject: [PATCH 3/5] fix(rs-platform-wallet/e2e): add parse_truthy helper needed by CR-004 env-gate `fafa5b4fa9` references `crate::framework::config::parse_truthy` which was introduced in `d1d81a3294` (DISABLE_SPV feature) on the source branch but is absent from `feat/rs-platform-wallet-e2e` at the base commit. Add just the helper function here so the env-gate compiles without pulling in the full DISABLE_SPV harness changes. Co-Authored-By: Claude Sonnet 4.6 --- .../tests/e2e/framework/config.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/rs-platform-wallet/tests/e2e/framework/config.rs b/packages/rs-platform-wallet/tests/e2e/framework/config.rs index 84a8fbe45d..1b5b5b9ed7 100644 --- a/packages/rs-platform-wallet/tests/e2e/framework/config.rs +++ b/packages/rs-platform-wallet/tests/e2e/framework/config.rs @@ -383,6 +383,20 @@ pub(crate) fn parse_bank_core_gate(raw: Option<&str>) -> (Option, Bank } } +/// Parse a boolean opt-in flag from a raw env-var value (`None` = unset). +/// +/// Truthy: `1`, `true`, `yes`, `on` (case-insensitive, trimmed). +/// Everything else — including empty / unset / unparseable — is `false`. +/// Used by [`vars::RUN_FAILING_BY_DESIGN`]. +pub(crate) fn parse_truthy(raw: Option<&str>) -> bool { + let Some(raw) = raw else { return false }; + let trimmed = raw.trim(); + trimmed == "1" + || trimmed.eq_ignore_ascii_case("true") + || trimmed.eq_ignore_ascii_case("yes") + || trimmed.eq_ignore_ascii_case("on") +} + /// Parse a network string supporting the canonical dashcore names /// plus the test-harness `local` alias for regtest and an empty /// shorthand for testnet. Used only at [`Config`] construction; From 57de4ee6f017a04d2c0a542ff6ce0b5eb78243e6 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 8 May 2026 15:06:23 +0200 Subject: [PATCH 4/5] refactor(rs-platform-wallet): drop CR-004 invariant tracing on hot path (#3613 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trim the 12-line TODO(CR-004 / dash-evo-tool#845) commentary on `send_to_addresses` post-broadcast hook to a 3-line marker pointing at the e2e test name. Drop the 9-line `tracing::debug!` call that fired on every standard-tx broadcast — it logged an invariant the CR-004 test asserts via state inspection, not via log scraping, and hot-path logging of static invariants violates the project logging policy (skill: coding-best-practices § Logging Levels). Addresses F-001 (MEDIUM) and F-002 (MEDIUM) in audit report. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/wallet/core/broadcast.rs | 25 +++---------------- 1 file changed, 3 insertions(+), 22 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index f610083679..37e5dff6a2 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -173,18 +173,9 @@ impl CoreWallet { // network resolves that race exactly as it does on `v3.1-dev` // today, but neither caller corrupts local state on a transient // broadcast failure. - // - // TODO(CR-004 / dash-evo-tool#845): pin that this post-broadcast - // hook actually mutates `standard_bip32_accounts` UTXO state — not - // just `standard_bip44_accounts`. The downstream - // `key_wallet::ManagedWalletInfo::check_core_transaction` routes - // standard txs through both BIP32 and BIP44 collections via - // `TransactionRouter::get_relevant_account_types`, but if a future - // routing regression skips BIP32 (the "legacy" path DET v0.9.x - // wallets still rely on), this call will silently leave UTXOs - // marked spendable on `standard_bip32_accounts[0]`. The CR-004 e2e - // test pins the contract end-to-end; this comment names the call - // site so a reviewer chasing that test failure lands here directly. + // Post-broadcast hook must mark consumed UTXOs spent on every + // standard-tx account collection (BIP44 + BIP32). Pinned by + // `cr_004_legacy_bip32_utxo_update_after_spend` (dash-evo-tool#845). { let mut wm = self.wallet_manager.write().await; let (wallet, info) = @@ -194,16 +185,6 @@ impl CoreWallet { "Wallet not found in wallet manager".to_string(), ) })?; - tracing::debug!( - target: "platform_wallet::core::broadcast", - txid = %tx.txid(), - account_type = ?account_type, - account_index, - inputs = tx.input.len(), - outputs = tx.output.len(), - "post-broadcast: dispatching check_core_transaction(Mempool) — \ - must mark consumed UTXOs spent on the matching account collection" - ); info.check_core_transaction(&tx, TransactionContext::Mempool, wallet, true, true) .await; } From e3f80ce7851d0a6f92acba3d604249c2a5db691d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 8 May 2026 15:06:29 +0200 Subject: [PATCH 5/5] docs(rs-platform-wallet): tighten CR-004 test prose to present-state (#3613 review) Trim the 33-line module docstring to a 10-line present-state summary; the call-site walkthrough and "Why FAILING-by-design and not WIRED" framing belong in the PR description / git history, not in source. Collapse multi-paragraph assertion messages to single-line essence naming the contract violated. Drop dangling references to "module docstring point 3" now that the numbered list is gone. Addresses F-003, F-004, F-005 (LOW) in audit report. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...04_legacy_bip32_utxo_update_after_spend.rs | 89 ++++--------------- 1 file changed, 17 insertions(+), 72 deletions(-) diff --git a/packages/rs-platform-wallet/tests/e2e/cases/cr_004_legacy_bip32_utxo_update_after_spend.rs b/packages/rs-platform-wallet/tests/e2e/cases/cr_004_legacy_bip32_utxo_update_after_spend.rs index b88d423097..a36eb1b441 100644 --- a/packages/rs-platform-wallet/tests/e2e/cases/cr_004_legacy_bip32_utxo_update_after_spend.rs +++ b/packages/rs-platform-wallet/tests/e2e/cases/cr_004_legacy_bip32_utxo_update_after_spend.rs @@ -1,36 +1,13 @@ //! CR-004 — Legacy BIP32 account: balance + UTXO state updates after spend. //! //! Spec: `tests/e2e/TEST_SPEC.md` (### Core (CR) → CR-004). -//! Pinned status: FAILING-by-design — the canonical contract for the -//! post-broadcast UTXO mutation on `standard_bip32_accounts` is asserted -//! end-to-end. Mirrors the bug surfaced by +//! Status: ignored, env-gated via `PLATFORM_WALLET_E2E_RUN_FAILING_BY_DESIGN`. +//! Pins the post-broadcast UTXO-mutation contract on +//! `standard_bip32_accounts` against //! [dashpay/dash-evo-tool#845](https://github.com/dashpay/dash-evo-tool/issues/845): -//! after a "send all" spend on the legacy BIP32 account, the wallet's -//! local UTXO set is left stale and a follow-up -//! [`CoreWallet::send_to_addresses`] fails with a coin-selection error -//! ("No UTXOs available for selection") instead of a clean -//! insufficient-funds path. -//! -//! Why FAILING-by-design and not WIRED: -//! 1. The bug ships from a downstream consumer (DET v0.9.x). The test -//! pins the rs-platform-wallet contract so a future routing -//! regression in `key_wallet::ManagedWalletInfo::check_core_transaction` -//! that drops `StandardBIP32` from the standard-tx routing table is -//! caught here, not in dash-evo-tool's issue tracker. -//! 2. The production fix lives behind the `wallet/core/broadcast.rs:185` -//! `check_core_transaction(Mempool, ...)` call site (now annotated with -//! a `TODO(CR-004)` breadcrumb). The downstream router currently DOES -//! iterate BIP32 (see `key_wallet::transaction_checking::transaction_router` -//! at the pinned revision — `StandardBIP32` is in -//! `get_relevant_account_types(TransactionType::Standard)`), but the -//! test exercises the contract end-to-end so any silent regression is -//! visible the moment a CI run flips status. -//! 3. The harness does NOT yet expose -//! `CoreWallet::next_receive_address_for_bip32_account` (the BIP-44 -//! sibling on `wallet/core/wallet.rs:59` has no BIP-32 counterpart); -//! the helper this test reaches for is implemented INLINE here. When -//! a parallel branch lifts that into the public surface, the inline -//! helper collapses to a one-line call. +//! a "send all" on the legacy BIP32 account must drain the local UTXO +//! set so a follow-up `send_to_addresses` fails cleanly on empty inputs +//! rather than reselecting phantom UTXOs. use std::time::Duration; @@ -110,11 +87,9 @@ async fn cr_004_legacy_bip32_utxo_update_after_spend() { .await .expect("setup (CR-004 — fresh-seeded test wallet with default account set)"); - // Step 2: derive the legacy BIP32 account 0 receive address INLINE. - // `CoreWallet` has no `next_receive_address_for_bip32_account` helper - // today (see module docstring); we mirror the BIP-44 helper's shape - // (`packages/rs-platform-wallet/src/wallet/core/wallet.rs:59`) directly - // against `standard_bip32_accounts`. + // Step 2: derive the legacy BIP32 account 0 receive address inline. + // `CoreWallet` has no `next_receive_address_for_bip32_account` helper; + // mirror the BIP-44 sibling shape against `standard_bip32_accounts`. let bip32_recv_1 = next_receive_address_for_bip32_account(&s.test_wallet, 0) .await .expect("derive legacy BIP32 receive address (slot 1)"); @@ -219,12 +194,7 @@ async fn cr_004_legacy_bip32_utxo_update_after_spend() { vec![(sink.clone(), send_all)], ) .await - .expect( - "send_to_addresses(BIP32Account, 0, send_all) — the legacy \ - BIP32 broadcast path must succeed; failure here means the \ - broadcast itself is broken, not the post-broadcast state \ - update CR-004 actually pins.", - ); + .expect("send_to_addresses(BIP32Account, 0, send_all) failed — broadcast path is broken"); tracing::info!( target: "platform_wallet::e2e::cases::cr_004", txid = %tx.txid(), @@ -254,18 +224,8 @@ async fn cr_004_legacy_bip32_utxo_update_after_spend() { ); assert_eq!( bip32_count_post, 0, - "POST-pin violated (dash-evo-tool#845): legacy BIP-32 account \ - 0 has {bip32_count_post} spendable UTXOs after a `send_to_addresses` \ - broadcast that consumed both inputs. The post-broadcast \ - `check_core_transaction(Mempool, ...)` call at \ - `wallet/core/broadcast.rs:185` must have marked the consumed \ - UTXOs spent on `standard_bip32_accounts[0]`. If this fires, \ - either (a) the routing in \ - `key_wallet::transaction_checking::transaction_router::TransactionRouter::get_relevant_account_types(TransactionType::Standard)` \ - dropped `StandardBIP32` (regression — the legacy DET 0.9.x \ - path), or (b) the post-broadcast hook is being invoked but \ - the `account_type_match` for BIP-32 has lost its mark-spent \ - side effect." + "BIP-32 account 0 has {bip32_count_post} spendable UTXOs after send-all \ + (dash-evo-tool#845 regression)" ); // Step 7: re-attempt a Core transfer on the now-drained legacy @@ -294,12 +254,7 @@ async fn cr_004_legacy_bip32_utxo_update_after_spend() { || msg.to_lowercase().contains("no spendable") || msg.to_lowercase().contains("coin selection") || msg.to_lowercase().contains("insufficient"), - "POST-pin violated: second send_to_addresses on a drained \ - legacy BIP-32 account failed with TransactionBuild but \ - the message does NOT identify the empty-input cause: \ - {msg:?}. The contract requires a clean \ - 'no spendable inputs' / 'insufficient' / coin-selection \ - message." + "TransactionBuild error does not name the empty-input cause: {msg:?}" ); tracing::info!( target: "platform_wallet::e2e::cases::cr_004", @@ -308,21 +263,11 @@ async fn cr_004_legacy_bip32_utxo_update_after_spend() { ); } Err(other) => { - panic!( - "POST-pin violated: second send_to_addresses on a drained \ - legacy BIP-32 account failed with {other:?} — expected \ - PlatformWalletError::TransactionBuild naming the empty \ - input set." - ); + panic!("expected TransactionBuild on drained BIP-32 account, got {other:?}"); } Ok(tx) => { panic!( - "POST-pin violated (dash-evo-tool#845): second \ - send_to_addresses on a drained legacy BIP-32 account \ - RETURNED Ok with txid {} — the wallet selected phantom \ - UTXOs that the post-broadcast hook should have marked \ - spent. This is the exact buggy path the upstream issue \ - reports.", + "drained BIP-32 account selected phantom UTXOs (dash-evo-tool#845): txid={}", tx.txid() ); } @@ -344,8 +289,8 @@ async fn cr_004_legacy_bip32_utxo_update_after_spend() { } // --------------------------------------------------------------------------- -// Inline helpers (lift to `framework/` when CR-004 graduates from -// FAILING-by-design — see module docstring point 3). +// Inline helpers — lift to `framework/` once a stable BIP-32 receive-address +// derivation point lands on `CoreWallet`. // --------------------------------------------------------------------------- /// Derive the next unused receive address on the wallet's legacy BIP-32