diff --git a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index 22edf7303a..18820abb49 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -182,6 +182,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. + // 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) = 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..a36eb1b441 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/cr_004_legacy_bip32_utxo_update_after_spend.rs @@ -0,0 +1,382 @@ +//! CR-004 — Legacy BIP32 account: balance + UTXO state updates after spend. +//! +//! Spec: `tests/e2e/TEST_SPEC.md` (### Core (CR) → CR-004). +//! 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): +//! 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; + +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() { + // 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() + .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; + // 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)"); + 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) failed — broadcast path is broken"); + 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, + "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 + // 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"), + "TransactionBuild error does not name the empty-input cause: {msg:?}" + ); + tracing::info!( + target: "platform_wallet::e2e::cases::cr_004", + msg, + "CR-004: post-drain second send failed cleanly (expected)" + ); + } + Err(other) => { + panic!("expected TransactionBuild on drained BIP-32 account, got {other:?}"); + } + Ok(tx) => { + panic!( + "drained BIP-32 account selected phantom UTXOs (dash-evo-tool#845): txid={}", + 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/` 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 +/// 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 0b9e09ea83..759f1c7f5a 100644 --- a/packages/rs-platform-wallet/tests/e2e/cases/mod.rs +++ b/packages/rs-platform-wallet/tests/e2e/cases/mod.rs @@ -8,6 +8,7 @@ pub mod cr_001_spv_mn_list_sync_readiness; 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; diff --git a/packages/rs-platform-wallet/tests/e2e/framework/config.rs b/packages/rs-platform-wallet/tests/e2e/framework/config.rs index 8d96205e08..9535172579 100644 --- a/packages/rs-platform-wallet/tests/e2e/framework/config.rs +++ b/packages/rs-platform-wallet/tests/e2e/framework/config.rs @@ -60,6 +60,21 @@ pub mod vars { /// gate is consumed test-side via [`super::spv_disabled_from_env`]. /// See `TEST_SPEC.md` CR-001 for the SPEC-level reference. 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 @@ -367,6 +382,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") +} + /// Returns `true` when [`vars::DISABLE_SPV`] is set to a truthy value /// (`1` / `true` / `yes` / `on`, case-insensitive, surrounding /// whitespace ignored). Any other value — including unset, empty, or