From 835bd80d8f9a90e53147a8a1660212718504805f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 5 May 2026 12:19:44 +0200 Subject: [PATCH 01/34] =?UTF-8?q?docs(rs-platform-wallet/e2e):=20add=20ID-?= =?UTF-8?q?007=20spec=20entry=20=E2=80=94=20identity-auth=20addresses=20un?= =?UTF-8?q?monitored=20contract=20pin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ID-007 pins the current contract that DIP-9 identity-auth addresses (`m/9'/coinType'/5'/0..3'/identity_index'/key_index'`) are NOT in `PlatformWalletInfo::monitored_addresses()` at the pinned `key-wallet` revision (`fe2476611f`). `WalletAccountCreationOptions::Default` does not create `BlockchainIdentities*` accounts, so a Core (Layer-1) send to one of those addresses is invisible to the SPV bloom filter and never increases the wallet's Core balance. Tracks the scenario from closed PR `dashpay/rust-dashcore#554` (the parked attempt to ship `BlockchainIdentities*` AccountType variants and flip Default to monitor those addresses) and DET follow-up issue `dash-evo-tool#692`. The PR was closed without merge or supersede pointer; investigation confirmed the scenario is silently unhandled when consumed by `rs-platform-wallet`. Status BLOCKED — full test body lands alongside this entry but is gated behind `#[ignore]` until: (1) Task #15 (SPV runtime), (2) Core-funded bank wallet helper (CR-003 prerequisite), (3) the `Bank::send_core_to` stub gets wired to a real Layer-1 broadcast. Defensive-pin precedent: same shape as Found-003 / Found-004 — pin a known-incomplete contract as an asserted invariant so silent drift becomes loud breakage. When upstream `key-wallet` ships any shape of `BlockchainIdentities` support and the wallet opts in, the assertions flip in that same PR. Co-Authored-By: Claude Opus 4.7 (1M context) Co-authored-by: Claudius the Magnificent --- .../rs-platform-wallet/tests/e2e/TEST_SPEC.md | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md b/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md index ee69243eeb..30e80d95dc 100644 --- a/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md +++ b/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md @@ -131,6 +131,7 @@ Source citations for the "Wallet API exists" column are listed inline per case | ID-003b | Concurrent identity-to-identity transfers serialise on identity nonce | P2 | M | | ID-005b | `transfer_credits_to_addresses` with empty outputs | P2 | S | | ID-006b | Identity-key derivation index boundary (`0` and `DEFAULT_GAP_LIMIT - 1`) | P2 | M | +| ID-007 | Identity-auth addresses are visible to SPV monitor (BLOCKED on Task #15) | P2 | M | | TK-001 | Token transfer between two identities | P1 | L | | TK-001b | Token transfer of amount 0 | P2 | S | | TK-001c | Token transfer across re-issued identity (signer rotation) | P2 | M | @@ -193,7 +194,7 @@ Source citations for the "Wallet API exists" column are listed inline per case | Found-017 | `register_wallet` registers wallet in memory even when persister `store` returns `Err` — vanishes on next launch | P2 | S | | Found-018 | `PlatformAddressChangeSet::merge` documents fee semantics as "fee paid by the transfer that produced this changeset" but actually accumulates fees across merged changesets | P2 | S | -Counts by priority: **P0: 10**, **P1: 24** (incl. 2 post-Task #15), **P2: 56** (incl. 1 post-Task #15, 1 gated, 18 Found-bug pins), **DEFERRED: 1** (91 total index entries; 72 baseline + 18 Found-bug pins + 1 deferred placeholder). +Counts by priority: **P0: 10**, **P1: 24** (incl. 2 post-Task #15), **P2: 57** (incl. 2 post-Task #15, 1 gated, 18 Found-bug pins), **DEFERRED: 1** (92 total index entries; 73 baseline + 18 Found-bug pins + 1 deferred placeholder). ### Platform Addresses (PA) @@ -892,6 +893,44 @@ Counts by priority: **P0: 10**, **P1: 24** (incl. 2 post-Task #15), **P2: 56** ( - **Estimated complexity**: M - **Rationale**: ID-006 covers `identity_index` boundaries; `key_index` is the parallel axis and currently uncovered. +#### ID-007 — Identity-auth addresses are visible to SPV monitor (BLOCKED on Task #15) +- **Priority**: P2 +- **Status**: BLOCKED — full test body implemented, gated behind `#[ignore]`. Will fail loudly the first time it's invoked under `--ignored` until: (1) SPV runtime is re-enabled (Task #15 — same gate as `CR-001`/`CR-002`/`CR-003`), (2) the Core-funded bank wallet helper lands (CR-003 prerequisite — current bank holds Platform credits, not Core duffs), (3) the framework's `bank.send_core_to(..)` helper is wired (currently stubbed with `unimplemented!()`). When all three exist, drop the `#[ignore]` to the standard "needs testnet" form and the test runs end-to-end. Tracks the scenario from closed PR `dashpay/rust-dashcore#554` (the parked attempt to ship `BlockchainIdentities*` AccountType variants and flip `WalletAccountCreationOptions::Default` to monitor those addresses) and DET follow-up issue `dash-evo-tool#692`. The wallet's contract today is "identity-auth addresses are NOT monitored"; this case pins that contract so any reshape upstream surfaces here rather than silently in DET or in user funds. +- **Wallet feature exercised**: `PlatformWalletInfo::monitored_addresses` (`wallet/platform_wallet_traits.rs:93`) projection for DIP-9 identity-authentication addresses derived via `derive_ecdsa_identity_auth_keypair_from_master` (`wallet/identity/network/identity_handle.rs:143`). Concretely: the `m/9'/coinType'/5'/0'/identity_index'/key_index'` subfeature path, which is not in `WalletAccountCreationOptions::Default` at the pinned `key-wallet` revision. +- **DET parallel**: `dash-evo-tool#692` (the follow-up issue PR `dashpay/rust-dashcore#554` referenced for the DET-side `spv_account_metadata()` match arm). +- **Preconditions**: + - SPV runtime enabled (Task #15 — gates `CR-001` too). + - ID-001 helper landed (Wave A). + - Bank wallet that holds **Core coins**, not just credits — same prerequisite as `CR-003`. Test is gated until that Core-funded helper exists. +- **Scenario**: + 1. `let id = setup_with_n_identities(1, 30_000_000).await?.identities[0];` + 2. Compute `auth_addr = P2PKH(derive_ecdsa_identity_auth_keypair_from_master(master, network, identity_index = 0, key_index = 0).public_key)`. + 3. Snapshot `wallet.monitored_addresses()` *before* sending anything. + 4. Send `100_000` duffs from the Core-funded bank to `auth_addr` on Layer-1; wait for instant-lock. + 5. Snapshot `wallet.monitored_addresses()` *after* the broadcast. + 6. Wait up to `30s` for the wallet's Core balance to reflect the incoming UTXO; record whether it does. +- **Assertions** (pin the **current** contract, not the aspirational one — flip to the aspirational shape only after the upstream decision lands and the relevant DET issue is closed): + - `auth_addr` is **NOT** in `monitored_addresses()` either before or after step 4 (current contract). + - The wallet's Core balance does **NOT** increase after step 6 within the timeout (current contract). + - The wallet's UTXO set does **NOT** contain the new `100_000`-duff UTXO (current contract). + - When the eventual `BlockchainIdentities` support lands upstream and the wallet opts in, **flip** all three assertions and the test starts passing for the right reason. +- **Negative variants** (covered inline in the same test — registration status is irrelevant, the derivation is pure): + - Compute `auth_addr` for `identity_index = 1` (an unregistered slot) — same three current-contract assertions hold. + - Repeat for the BLS subfeature path (`m/9'/coinType'/5'/2'/identity_index'/key_index'`) once `derive_*_bls_identity_auth_keypair_from_master` lands; assert the same negative. (Deferred — TODO comment in the test body.) +- **Harness extensions required**: + - SPV runtime re-enabled (Task #15 — same prerequisite as `CR-001`). + - Core-funded bank wallet helper (same prerequisite as `CR-003`). Stubbed for now via `Bank::send_core_to(..) -> unimplemented!()`; wire through when CR-003 helpers land. + - `wait_for_core_balance(wallet, expected_min, timeout)` — landed in `framework/wait.rs` alongside this case (parallel of `wait_for_balance` for Layer-1 balance instead of credits). + - Wave A's `SeedBackedIdentitySigner` (already needed for `ID-001`). +- **Estimated complexity**: M (test body is short — most of the cost is the prerequisite SPV + Core-faucet bring-up that `CR-001` and `CR-003` already require). +- **Funding budget**: `100_000` Core duffs (~0.001 DASH) per run for the Layer-1 send; rounding for Core-tx fee. Negligible compared to the credit budget of any P0/P1 case. +- **Rationale**: Pins the wallet's contract for "which DIP-9 subfeatures get monitored?" The closed PR `dashpay/rust-dashcore#554` user story explicitly called out identity-auth addresses as a scenario it wanted SPV-monitored; the PR is closed without merge or supersede pointer, and the current contract in the pinned `key-wallet` rev silently excludes them. ID-007 makes that exclusion an asserted contract so that: + 1. anyone who flips `WalletAccountCreationOptions::Default` to include `BlockchainIdentities*` accounts (or any equivalent reshape upstream) breaks this test loudly, and the assertion bodies can be flipped in the same PR; + 2. nobody on the platform side accidentally relies on the monitored-addresses set covering identity-auth addresses before the upstream story lands. +- **Notes**: + - Today `derive_ecdsa_identity_auth_keypair_from_master` is the only DIP-9 subfeature `rs-platform-wallet` exposes (subfeature 0, ECDSA). Adding the BLS / Hash160 negative variants is contingent on the upstream `key-wallet` API gaining BLS derivation helpers. + - This is a **defensive contract pin**, not a feature test. Same shape as `Found-003` / `Found-004` — pin a known-incomplete behaviour as the contract until someone explicitly extends it. + ### Tokens (TK) The wallet has token operations on the API surface From 8325a26c38d275effa116ce6a1def3b2da702ecc Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 5 May 2026 12:20:06 +0200 Subject: [PATCH 02/34] =?UTF-8?q?test(rs-platform-wallet/e2e):=20add=20ID-?= =?UTF-8?q?007=20=E2=80=94=20pin=20contract=20that=20identity-auth=20addre?= =?UTF-8?q?sses=20are=20unmonitored?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the full scenario from TEST_SPEC.md § ID-007. The test is `#[ignore]`-gated (does not run in `cargo test --lib` or default `cargo test --tests`), so CI stays green; running it under `--ignored` will fail today on the `unimplemented!()` stub of `BankWallet::send_core_to` until Task #15 + the Core-funded bank helper land. Scenario: 1. Register one identity at slot 0 via `setup_with_n_identities`. 2. Derive the P2PKH `dashcore::Address` for `(identity_index = 0, key_index = 0)` via `derive_ecdsa_identity_auth_keypair_from_master`, plus `(identity_index = 1, key_index = 0)` for the in-test negative variant (registration status is irrelevant — the derivation is pure). 3. Snapshot `monitored_addresses()` before any Core send and assert neither address is in the set. 4. Attempt a 100_000-duff Layer-1 send via `bank().send_core_to(..)` (currently `unimplemented!()`). 5. Snapshot `monitored_addresses()` again and assert the contract still excludes both addresses. 6. `wait_for_core_balance` for 30 s expecting timeout — the SPV bloom filter must not carry these addresses, so the balance never moves. 7. Assert no UTXO matching `(value = 100_000, address = auth_addr_zero)` exists in the wallet's UTXO set. Framework additions: - `framework::wait::wait_for_core_balance(test_wallet, expected_min, timeout)` — Layer-1-balance parallel of `wait_for_balance`. Polls `PlatformWallet::state().balance().spendable()` every backstop interval. Re-exported via `framework::prelude`. - `BankWallet::send_core_to(target, duffs) -> unimplemented!()` — CR-003 prerequisite stub. The bank today holds Platform credits via DIP-17 platform-payment accounts, not Core duffs on a DIP-9 / BIP-44 receive account; wire it through when Task #15 exposes a Core-funded account. The BLS subfeature negative variant is left as a `TODO(ID-007)` comment in the test body — `derive_*_bls_identity_auth_keypair_from_master` doesn't exist in the upstream `key-wallet` API yet. Defensive-pin precedent: Found-003 / Found-004. Co-Authored-By: Claude Opus 4.7 (1M context) Co-authored-by: Claudius the Magnificent --- ...7_identity_auth_addresses_not_monitored.rs | 250 ++++++++++++++++++ .../rs-platform-wallet/tests/e2e/cases/mod.rs | 1 + .../tests/e2e/framework/bank.rs | 27 ++ .../tests/e2e/framework/mod.rs | 2 +- .../tests/e2e/framework/wait.rs | 61 +++++ 5 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 packages/rs-platform-wallet/tests/e2e/cases/id_007_identity_auth_addresses_not_monitored.rs diff --git a/packages/rs-platform-wallet/tests/e2e/cases/id_007_identity_auth_addresses_not_monitored.rs b/packages/rs-platform-wallet/tests/e2e/cases/id_007_identity_auth_addresses_not_monitored.rs new file mode 100644 index 0000000000..9ccab5be73 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/id_007_identity_auth_addresses_not_monitored.rs @@ -0,0 +1,250 @@ +//! ID-007 — Identity-auth addresses are NOT visible to SPV monitor. +//! +//! Spec: `tests/e2e/TEST_SPEC.md` (### Identity (ID) → ID-007). +//! Pinned status: BLOCKED — full test body implemented, gated behind +//! `#[ignore]`. Tracks closed PR `dashpay/rust-dashcore#554` (the +//! parked attempt to add `BlockchainIdentities*` `AccountType` +//! variants and flip `WalletAccountCreationOptions::Default` to +//! monitor those addresses) and DET follow-up issue +//! `dash-evo-tool#692`. +//! +//! Pins the CURRENT contract: +//! - identity-auth addresses derived via +//! [`derive_ecdsa_identity_auth_keypair_from_master`] are NOT in +//! [`WalletInfoInterface::monitored_addresses`] (because they live +//! on a DIP-9 subfeature path not in +//! `WalletAccountCreationOptions::Default` at the pinned +//! `key-wallet` revision). +//! - Sending Core duffs to one of those addresses does NOT increase +//! the wallet's Core balance (the SPV bloom filter ignores them). +//! - The wallet's UTXO set never observes such a send. +//! +//! When `BlockchainIdentities` support lands upstream and the wallet +//! opts in (any shape — four concrete variants, parameterised +//! subfeature, etc.), FLIP these assertions and the test starts +//! passing for the right reason. The defensive-pin precedent matches +//! `Found-003` / `Found-004`. + +use std::time::Duration; + +use dashcore::secp256k1::PublicKey as SecpPublicKey; +use dashcore::{Address, Network, PublicKey}; +use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; +use key_wallet::wallet::root_extended_keys::RootExtendedPrivKey; +use platform_wallet::wallet::identity::network::derive_ecdsa_identity_auth_keypair_from_master; + +use crate::framework::prelude::*; + +/// Funding committed to the registered identity. Modest — the +/// scenario doesn't need a fat identity, only one that exists so the +/// `identity_index = 0` slot is canonically "in use". +const REGISTRATION_FUNDING: u64 = 30_000_000; + +/// Layer-1 send amount targeted at the identity-auth address. ~0.001 +/// DASH; well above the dust threshold so the bank's would-be Core +/// path doesn't reject it on amount alone, well below any per-test +/// budget concern. +const CORE_SEND_DUFFS: u64 = 100_000; + +/// Negative-window for `wait_for_core_balance`: the test pins that +/// the Core balance does NOT reach `CORE_SEND_DUFFS` even after this +/// long, so the wait is EXPECTED to time out under the current +/// contract. Marvin's spec uses 30 seconds; matched here. +const CORE_BALANCE_NEGATIVE_WINDOW: Duration = Duration::from_secs(30); + +#[ignore = "ID-007 — BLOCKED on Task #15 (SPV runtime) + Core-funded bank \ + helper (CR-003 prerequisite). Pins the contract that DIP-9 \ + identity-auth addresses are NOT in monitored_addresses(). \ + Tracks closed PR dashpay/rust-dashcore#554."] +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn id_007_identity_auth_addresses_not_monitored() { + 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: register one identity at slot 0 with modest funding. + // Reuses `setup_with_n_identities` so the canonical identity- + // funding path is exercised; the identity itself isn't load- + // bearing in the assertions, only that slot 0 is "in use". + let s = crate::framework::setup_with_n_identities(1, REGISTRATION_FUNDING) + .await + .expect("setup_with_n_identities failed"); + let identity_zero = s + .identities + .first() + .expect("setup_with_n_identities returned no identities"); + tracing::info!( + target: "platform_wallet::e2e::cases::id_007", + identity_id = %identity_zero.id, + "registered slot-0 identity for ID-007" + ); + + let network = s.base.ctx.config.network; + let seed_bytes = s.base.test_wallet.seed_bytes(); + + // Derive `auth_addr` for (identity_index = 0, key_index = 0) — + // the slot we just registered. Pure derivation; bypasses the + // wallet's `AccountCollection` entirely. P2PKH the resulting + // pubkey to get a Core (Layer-1) address. + let auth_addr_zero = derive_auth_address(&seed_bytes, network, 0, 0) + .expect("derive identity-auth address (identity_index=0, key_index=0)"); + + // Negative variant — same derivation at an UNREGISTERED slot. + // Registration status is irrelevant to monitoring (the + // derivation is pure), so the same three current-contract + // assertions hold. + let auth_addr_one = derive_auth_address(&seed_bytes, network, 1, 0) + .expect("derive identity-auth address (identity_index=1, key_index=0)"); + + // TODO(ID-007): add BLS subfeature negative variant once + // `derive_*_bls_identity_auth_keypair_from_master` lands in the + // upstream `key-wallet` API. Path: + // `m/9'/coinType'/5'/2'/identity_index'/key_index'`. Same three + // current-contract assertions are expected to hold. + + // Step 3: snapshot `monitored_addresses()` BEFORE any Core send. + // The wallet has been live since `setup_with_n_identities` + // returned, so this is the steady-state monitored set. + let monitored_before = s + .base + .test_wallet + .platform_wallet() + .state() + .await + .monitored_addresses(); + assert!( + !monitored_before.contains(&auth_addr_zero), + "PRE-pin violated: identity-auth address (slot 0) already in \ + monitored_addresses(). The current contract at the pinned \ + key-wallet revision excludes DIP-9 subfeature 0..3 from \ + WalletAccountCreationOptions::Default; if this fires, \ + upstream has flipped the contract and this test must flip \ + its assertions in the same PR." + ); + assert!( + !monitored_before.contains(&auth_addr_one), + "PRE-pin violated: identity-auth address (slot 1, unregistered) \ + already in monitored_addresses(). Registration status is \ + irrelevant — the derivation is pure — so the same contract \ + applies to every (identity_index, key_index) pair." + ); + + // Step 4: send `CORE_SEND_DUFFS` from the bank to `auth_addr_zero` + // on Layer-1. Today this is `unimplemented!()` — see + // `BankWallet::send_core_to`. When Task #15 + the Core-funded + // bank helper land, replace the stub with a real broadcast and + // wait for the instant-lock event. + let pre_balance = s + .base + .test_wallet + .platform_wallet() + .state() + .await + .balance() + .spendable(); + let _txid = s + .base + .ctx + .bank() + .send_core_to(&auth_addr_zero, CORE_SEND_DUFFS) + .await + .expect("bank.send_core_to (CR-003 prerequisite — currently unimplemented!)"); + + // Step 5: snapshot `monitored_addresses()` AFTER the broadcast. + // The bloom filter regenerates from `accounts.all_accounts()`, + // which still excludes the BlockchainIdentities subfeature, so + // the set must be unchanged with respect to `auth_addr_*`. + let monitored_after = s + .base + .test_wallet + .platform_wallet() + .state() + .await + .monitored_addresses(); + assert!( + !monitored_after.contains(&auth_addr_zero), + "POST-pin violated (slot 0): identity-auth address appeared in \ + monitored_addresses() after a Layer-1 send. Upstream has \ + silently begun monitoring DIP-9 subfeature 0..3; flip the \ + assertions in the same PR that wires the change." + ); + assert!( + !monitored_after.contains(&auth_addr_one), + "POST-pin violated (slot 1): identity-auth address for an \ + unregistered slot appeared in monitored_addresses() after a \ + Layer-1 send. The send didn't even target this slot — \ + something has flipped the default monitored set." + ); + + // Step 6: wait UP TO `CORE_BALANCE_NEGATIVE_WINDOW` for the Core + // balance to reflect the inbound UTXO. Per the current contract + // it MUST NOT — the SPV bloom filter doesn't carry `auth_addr_zero`, + // so the UTXO is invisible to the wallet. We pin the timeout as + // EXPECTED. + let core_wait = wait_for_core_balance( + &s.base.test_wallet, + pre_balance + 1, + CORE_BALANCE_NEGATIVE_WINDOW, + ) + .await; + assert!( + core_wait.is_err(), + "POST-pin violated: wallet observed a Core balance increase \ + after sending to an identity-auth address. Either upstream \ + flipped the monitored-set contract, or the SPV path now \ + reaches into DIP-9 subfeature 0..3 by some other route. \ + Either way, ID-007 must flip its assertions in the same PR. \ + (observed value: {:?})", + core_wait.ok() + ); + + // Step 7: snapshot the UTXO set and assert it does not contain + // a `CORE_SEND_DUFFS`-valued entry to `auth_addr_zero`. + let utxo_count_to_auth_addr = s + .base + .test_wallet + .platform_wallet() + .state() + .await + .utxos() + .iter() + .filter(|u| u.value() == CORE_SEND_DUFFS && u.address == auth_addr_zero) + .count(); + assert_eq!( + utxo_count_to_auth_addr, 0, + "POST-pin violated: the wallet's UTXO set contains a \ + {CORE_SEND_DUFFS}-duff entry to the identity-auth address. \ + The SPV bloom filter must have started carrying DIP-9 \ + subfeature 0..3 — flip the assertions and document the new \ + contract." + ); + + s.teardown().await.expect("teardown"); +} + +/// Derive the P2PKH `dashcore::Address` for the identity-auth keypair +/// at `(identity_index, key_index)` on `network`. Mirrors the +/// derivation in `framework::signer::derive_identity_key` but stops +/// at the public-key → address step instead of building an +/// `IdentityPublicKey`. +fn derive_auth_address( + seed_bytes: &[u8; 64], + network: Network, + identity_index: u32, + key_index: u32, +) -> Result { + let root_priv = RootExtendedPrivKey::new_master(seed_bytes) + .map_err(|err| format!("invalid seed for root xpriv: {err}"))?; + let master = root_priv.to_extended_priv_key(network); + let derived = + derive_ecdsa_identity_auth_keypair_from_master(&master, network, identity_index, key_index) + .map_err(|err| format!("derive ({identity_index}, {key_index}): {err}"))?; + let secp_pubkey = SecpPublicKey::from_slice(&derived.public_key).map_err(|err| { + format!("public_key bytes from derive are not a valid secp256k1 pubkey: {err}") + })?; + Ok(Address::p2pkh(&PublicKey::new(secp_pubkey), network)) +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/mod.rs b/packages/rs-platform-wallet/tests/e2e/cases/mod.rs index 8f40c4f333..b09ab25510 100644 --- a/packages/rs-platform-wallet/tests/e2e/cases/mod.rs +++ b/packages/rs-platform-wallet/tests/e2e/cases/mod.rs @@ -11,6 +11,7 @@ pub mod id_001_register_identity_from_addresses; pub mod id_002_top_up_identity; pub mod id_003_identity_to_identity_transfer; pub mod id_005_identity_to_addresses_transfer; +pub mod id_007_identity_auth_addresses_not_monitored; pub mod id_sweep_recovers_identity_credits; pub mod pa_001_multi_output; pub mod pa_001b_change_address_branch; diff --git a/packages/rs-platform-wallet/tests/e2e/framework/bank.rs b/packages/rs-platform-wallet/tests/e2e/framework/bank.rs index 130e5bbbdf..599e5ad472 100644 --- a/packages/rs-platform-wallet/tests/e2e/framework/bank.rs +++ b/packages/rs-platform-wallet/tests/e2e/framework/bank.rs @@ -363,6 +363,33 @@ impl BankWallet { pub fn funding_mutex_history(&self) -> Vec { drain_funding_mutex_history() } + + /// Send `duffs` of Layer-1 Core duffs from the bank to a Core + /// `dashcore::Address`. Stubbed `unimplemented!()` — the bank + /// today holds Platform credits, not Core coins (see CR-003's + /// "Core-funded bank wallet helper" prerequisite). Wired in when + /// Task #15 (SPV runtime) lands and the bank gains a Core-funded + /// account. + /// + /// Used by `ID-007` to attempt a Layer-1 send to a DIP-9 + /// identity-auth address; the assertion side of that test + /// pins "the Core balance does NOT increase" against the + /// pinned `key-wallet` revision's contract. + pub async fn send_core_to( + &self, + target: &dashcore::Address, + duffs: u64, + ) -> FrameworkResult { + let _ = (target, duffs); + unimplemented!( + "BankWallet::send_core_to — CR-003 prerequisite. The bank \ + today holds Platform credits via DIP-17 platform-payment \ + accounts, not Core duffs on a DIP-9 / BIP-44 receive \ + account. Wire through when Task #15 (SPV runtime) lands \ + and the bank exposes a Core-funded account; see TEST_SPEC.md \ + § ID-007 / § CR-003 for the gating discussion." + ); + } } fn wallet_err(err: PlatformWalletError) -> FrameworkError { diff --git a/packages/rs-platform-wallet/tests/e2e/framework/mod.rs b/packages/rs-platform-wallet/tests/e2e/framework/mod.rs index c6d3cf576c..74351b4607 100644 --- a/packages/rs-platform-wallet/tests/e2e/framework/mod.rs +++ b/packages/rs-platform-wallet/tests/e2e/framework/mod.rs @@ -67,7 +67,7 @@ pub(super) fn make_platform_signer( pub mod prelude { pub use super::config::Config; pub use super::harness::E2eContext; - pub use super::wait::{wait_for, wait_for_balance}; + pub use super::wait::{wait_for, wait_for_balance, wait_for_core_balance}; pub use super::wait_hub::WaitEventHub; pub use super::{setup, FrameworkError, FrameworkResult, SetupGuard}; } diff --git a/packages/rs-platform-wallet/tests/e2e/framework/wait.rs b/packages/rs-platform-wallet/tests/e2e/framework/wait.rs index d7e0dd8689..b9b4e97366 100644 --- a/packages/rs-platform-wallet/tests/e2e/framework/wait.rs +++ b/packages/rs-platform-wallet/tests/e2e/framework/wait.rs @@ -16,6 +16,7 @@ use dpp::fee::Credits; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::Identity; use dpp::prelude::Identifier; +use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use super::wallet_factory::TestWallet; use super::{FrameworkError, FrameworkResult}; @@ -123,6 +124,66 @@ pub async fn wait_for_balance( } } +/// Wait for the wallet's Layer-1 Core balance (in duffs) to reach at +/// least `expected_min`. +/// +/// Polls `test_wallet.platform_wallet().state().await.balance().spendable()` +/// every [`BACKSTOP_WAKE_INTERVAL`] until the threshold is met. The +/// SPV bloom-filter feed updates the underlying `WalletCoreBalance` +/// asynchronously, so a poll-based approach is sufficient — there's +/// no `Notified` future on the Core side analogous to +/// [`wait_for_balance`]'s wait hub. Returns +/// [`FrameworkError::Cleanup`] on `timeout`, the standard "did not +/// reach target in time" sentinel used by the other waiters. +/// +/// Used by `ID-007` (pin: identity-auth addresses are NOT in +/// `monitored_addresses()`, so a Core send to one MUST time out +/// here at the pinned `key-wallet` revision); generally useful for +/// any future case asserting positive-balance arrival on a +/// monitored address. +pub async fn wait_for_core_balance( + test_wallet: &TestWallet, + expected_min: u64, + timeout: Duration, +) -> FrameworkResult { + let start = Instant::now(); + let deadline = Instant::now() + timeout; + + loop { + let observed = test_wallet + .platform_wallet() + .state() + .await + .balance() + .spendable(); + if observed >= expected_min { + tracing::info!( + target: "platform_wallet::e2e::wait", + observed, + expected_min, + elapsed = ?start.elapsed(), + "core balance reached target" + ); + return Ok(observed); + } + tracing::debug!( + target: "platform_wallet::e2e::wait", + observed, + expected_min, + "core balance below target" + ); + + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(FrameworkError::Cleanup(format!( + "wait_for_core_balance timed out after {timeout:?} \ + (expected_min={expected_min})" + ))); + } + tokio::time::sleep(std::cmp::min(remaining, BACKSTOP_WAKE_INTERVAL)).await; + } +} + /// Wait for an on-chain identity balance to reach at least `expected`. /// /// Polls `Identity::fetch(sdk, identity_id)` every From 318d83bd4e55cdf9c5f2d0906f05d00d8dd6566b Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Tue, 5 May 2026 18:48:32 +0700 Subject: [PATCH 03/34] feat(dashmate): default-on the BIP158 compact-filter index across all presets (#3587) Co-authored-by: Claude Opus 4.7 (1M context) --- .../configs/defaults/getBaseConfigFactory.js | 7 +++++++ .../configs/defaults/getLocalConfigFactory.js | 8 ++++++++ .../configs/getConfigFileMigrationsFactory.js | 14 ++++++++++++++ packages/dashmate/src/config/configJsonSchema.js | 8 ++++++++ packages/dashmate/templates/core/dash.conf.dot | 11 +++++++++++ 5 files changed, 48 insertions(+) diff --git a/packages/dashmate/configs/defaults/getBaseConfigFactory.js b/packages/dashmate/configs/defaults/getBaseConfigFactory.js index 615381179b..e21b2111e8 100644 --- a/packages/dashmate/configs/defaults/getBaseConfigFactory.js +++ b/packages/dashmate/configs/defaults/getBaseConfigFactory.js @@ -155,6 +155,13 @@ export default function getBaseConfigFactory() { }, }, indexes: [], + // BIP158 cfilter index + NODE_COMPACT_FILTERS service bit. + // Default-on across every preset so dashmate-managed nodes + // are BIP157 SPV-friendly out of the box. Operators who + // can't spare the cfilter index disk overhead (~10% of + // chain size on mainnet) can flip this off via + // `dashmate config set core.compactFilters false`. + compactFilters: true, }, platform: { quorumList: { diff --git a/packages/dashmate/configs/defaults/getLocalConfigFactory.js b/packages/dashmate/configs/defaults/getLocalConfigFactory.js index 409c36b1ce..9c9120b345 100644 --- a/packages/dashmate/configs/defaults/getLocalConfigFactory.js +++ b/packages/dashmate/configs/defaults/getLocalConfigFactory.js @@ -36,6 +36,14 @@ export default function getLocalConfigFactory(getBaseConfig) { zmq: { port: 49998, }, + // Mirrors the `core.compactFilters: true` set on the base + // config; restated explicitly here because the local + // preset is the canonical surface where dev BIP157 SPV + // clients (e.g. the swift-sdk iOS example app pointed at + // `local_seed`) need cfilter sync to work, and we want + // that requirement to survive any future flip of the base + // default. + compactFilters: true, }, dashmate: { helper: { diff --git a/packages/dashmate/configs/getConfigFileMigrationsFactory.js b/packages/dashmate/configs/getConfigFileMigrationsFactory.js index a8d6018cb5..adaa072685 100644 --- a/packages/dashmate/configs/getConfigFileMigrationsFactory.js +++ b/packages/dashmate/configs/getConfigFileMigrationsFactory.js @@ -1413,6 +1413,20 @@ export default function getConfigFileMigrationsFactory(homeDir, defaultConfigs) const isLocal = options.network === NETWORK_LOCAL || name === 'local'; const isTestnet = options.network === NETWORK_TESTNET || name === 'testnet'; + // Flip `core.compactFilters` to true for every config — + // pre-3.1.0 configs predate the field entirely (template + // emitted nothing, dashcore left the cfilter index off), + // so a missing-or-false value here always means + // "inherited the old implicit-off default" rather than + // "user explicitly opted out". The base config now + // ships with this flag on; this backfill brings every + // already-set-up cluster up to that line so the iOS + // BIP157 SPV flow against `local_seed` (and any other + // dashmate node) works without manual editing. + if (options.core) { + options.core.compactFilters = true; + } + if (options.platform?.drive?.tenderdash?.docker && defaultConfig.has('platform.drive.tenderdash.docker.image')) { options.platform.drive.tenderdash.docker.image = defaultConfig diff --git a/packages/dashmate/src/config/configJsonSchema.js b/packages/dashmate/src/config/configJsonSchema.js index 3b53f61a5b..ecf969bde6 100644 --- a/packages/dashmate/src/config/configJsonSchema.js +++ b/packages/dashmate/src/config/configJsonSchema.js @@ -487,6 +487,14 @@ export default { description: 'List of core indexes to enable. `platform.enable`, ' + ' `core.masternode.enable`, and `core.insight.enabled` add indexes dynamically', }, + compactFilters: { + type: 'boolean', + description: 'Build the BIP158 cfilter index and advertise ' + + 'NODE_COMPACT_FILTERS to peers, so BIP157 SPV clients can sync ' + + 'filter headers + filters from this node. Defaults to true on ' + + 'every preset; flip to false to skip the cfilter index ' + + '(~10% chain-size disk overhead on mainnet).', + }, }, required: ['docker', 'p2p', 'rpc', 'zmq', 'spork', 'masternode', 'miner', 'devnet', 'log', 'indexes', 'insight'], diff --git a/packages/dashmate/templates/core/dash.conf.dot b/packages/dashmate/templates/core/dash.conf.dot index c07f8f18cb..c86b84bccc 100644 --- a/packages/dashmate/templates/core/dash.conf.dot +++ b/packages/dashmate/templates/core/dash.conf.dot @@ -69,6 +69,17 @@ spentindex=1 {{~}} {{?}} +# BIP157/158 compact block filters. Building the cfilter index plus +# advertising NODE_COMPACT_FILTERS lets BIP157 SPV clients sync +# headers + filters from this node. Off by default (mainnet/testnet +# usage doesn't justify the disk + CPU overhead); enabled on the +# `local` preset where the chain is tiny and the iOS dev flow needs +# filter sync against dashmate's `local_seed` to test the wallet. +{{? it.core.compactFilters }} +blockfilterindex=basic +peerblockfilters=1 +{{?}} + # ZeroMQ notifications zmqpubrawtx=tcp://0.0.0.0:{{=it.core.zmq.port}} zmqpubrawtxlock=tcp://0.0.0.0:{{=it.core.zmq.port}} From 37b0f76c5587f1f5d8654389cd0c8c860d684ec3 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 5 May 2026 14:10:36 +0200 Subject: [PATCH 04/34] test(rs-platform-wallet/e2e): re-enable SPV runtime in framework setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The e2e test framework was constructing a wallet manager without starting the SPV runtime, leaving identity-auth address monitoring (and other Layer-1 dependent assertions) unable to verify their contracts. ID-007 specifically requires SPV to be alive for the monitored_addresses() snapshot to be meaningful. Re-enables `SpvRuntime::start(ClientConfig::testnet())` during `setup()`, with the existing 180s mn-list-sync deadline (which the helper internally raises to the 600s cold-cache floor). SDK keeps `TrustedHttpContextProvider` — proof verification doesn't need the SPV-backed quorum lookup yet; future tests that do can swap to `SpvContextProvider::new(spv_runtime)` via `sdk.set_context_provider` (it's `ArcSwap`-backed, safe to call post-construction). Verified with ID-007 on testnet: SPV mn-list synced from cold cache in ~90s, and the test correctly proceeded through bank L1 funding (`balance reached target observed=130000000`) before the unrelated identity-registration headroom panic in `setup_with_n_identities` (see follow-up: REGISTRATION_HEADROOM=100M < required ~110.86M). Co-Authored-By: Claude Opus 4.7 (1M context) 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- .../tests/e2e/framework/harness.rs | 60 +++++++++---------- 1 file changed, 29 insertions(+), 31 deletions(-) diff --git a/packages/rs-platform-wallet/tests/e2e/framework/harness.rs b/packages/rs-platform-wallet/tests/e2e/framework/harness.rs index 4d16dc161e..bbffd879b0 100644 --- a/packages/rs-platform-wallet/tests/e2e/framework/harness.rs +++ b/packages/rs-platform-wallet/tests/e2e/framework/harness.rs @@ -4,15 +4,16 @@ //! [`TrustedHttpContextProvider`]) → manager → bank → registry → //! startup sweep. //! -//! SPV-based context provider currently disabled; re-enable by -//! uncommenting the SPV blocks in `Self::build` (Task #15). +//! SPV runtime is started during `Self::build` so monitored-address +//! / Layer-1 contracts have something live to observe. The SDK keeps +//! the trusted HTTP context provider for now — tests that need +//! SPV-backed proof verification can swap to `SpvContextProvider`. use std::fs::File; use std::path::PathBuf; use std::sync::Arc; +use std::time::Duration; -// `SpvRuntime` is held in an `Option` for SPV re-enablement -// (Task #15); the corresponding helpers stay compilable. use platform_wallet::wallet::persister::NoPlatformPersistence; use platform_wallet::{PlatformEventHandler, PlatformWalletManager, SpvRuntime}; use tokio::sync::OnceCell; @@ -24,10 +25,16 @@ use super::cleanup; use super::config::Config; use super::registry::PersistentTestWalletRegistry; use super::sdk; +use super::spv; use super::wait_hub::WaitEventHub; use super::workdir; use super::FrameworkResult; +/// Deadline for the SPV mn-list to reach `Synced` during framework +/// init. Internally raised to `COLD_CACHE_TIMEOUT_FLOOR` (600s) by +/// [`spv::wait_for_mn_list_synced`] so cold testnet caches still fit. +const SPV_READY_TIMEOUT: Duration = Duration::from_secs(180); + /// Process-shared singleton populated on first /// [`E2eContext::init`]. static CTX: OnceCell = OnceCell::const_new(); @@ -44,8 +51,11 @@ pub struct E2eContext { workdir_lock: File, pub sdk: Arc, pub manager: Arc>, - /// `None` while the SPV-based context provider is deferred - /// (Task #15); shape kept stable for future re-enablement. + /// SPV runtime started by [`Self::build`]. The SDK still uses + /// the trusted HTTP context provider; this handle is exposed via + /// [`Self::spv`] for tests that need to observe SPV state + /// directly. Held as `Option` so individual setups can opt out + /// without breaking the type — current default is `Some`. pub spv_runtime: Option>, pub bank: BankWallet, /// Identity-credit sweep destination — registered or loaded once @@ -94,8 +104,7 @@ impl E2eContext { &self.registry } - /// `None` while the SPV-based context provider is deferred - /// (Task #15). + /// Live SPV runtime started by [`Self::build`]. pub fn spv(&self) -> Option<&Arc> { self.spv_runtime.as_ref() } @@ -132,29 +141,18 @@ impl E2eContext { event_handler, )); - // SPV deferred (Task #15) — `TrustedHttpContextProvider` - // is wired at SDK construction in `sdk::build_sdk`. To - // re-enable the SPV-backed provider, uncomment below and - // restore the `spv` / `context_provider` imports. - // - // ```rust,ignore - // const SPV_READY_TIMEOUT: Duration = Duration::from_secs(180); - // use super::context_provider::SpvContextProvider; - // use super::spv; - // // Start SPV before the bank's sync; SDK proof - // // verification needs SpvContextProvider for quorum keys. - // // Pass the SDK's live address list so SPV peers stay in - // // lock-step with the DAPI endpoints the SDK is actually - // // talking to (port-swapped to the effective P2P port). - // let spv_runtime = spv::start_spv(&manager, &config, &workdir, sdk.address_list()).await?; - // spv::wait_for_mn_list_synced(&spv_runtime, SPV_READY_TIMEOUT).await?; - // // `set_context_provider` is `ArcSwap`-backed, safe to - // // call after construction. - // sdk.set_context_provider(SpvContextProvider::new( - // Arc::clone(&spv_runtime), - // )); - // ``` - let spv_runtime: Option> = None; + // Start SPV before the bank loads so any L1 funding / + // monitored-address contract assertions have a live mn-list + // to observe. SDK keeps `TrustedHttpContextProvider` — + // tests that need SPV-quorum-backed proof verification can + // switch via `sdk.set_context_provider(SpvContextProvider::new(...))` + // (it's `ArcSwap`-backed, safe to call after construction). + // Address-list seeding pins SPV peers to the same DAPI hosts + // the SDK is talking to (port-swapped to the P2P port), so + // tests don't drift between two independent peer pools. + let spv_runtime = spv::start_spv(&manager, &config, &workdir, sdk.address_list()).await?; + spv::wait_for_mn_list_synced(&spv_runtime, SPV_READY_TIMEOUT).await?; + let spv_runtime: Option> = Some(spv_runtime); // Panics on under-funded balance — see `BankWallet::load`. let bank = BankWallet::load(&manager, &config).await?; From 578a6dae32dd3c6e7ca8a36096fe0f4cb21d34e7 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 5 May 2026 14:21:10 +0200 Subject: [PATCH 05/34] fix(rs-platform-wallet/e2e): bump REGISTRATION_HEADROOM to 150M for current dynamic fee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The setup_with_n_identities helper was sizing each funding address as `funding_per + 100M`, which on the current testnet falls short of the dynamic IdentityCreateFromAddresses fee. Marvin's diagnosis pegs that fee at ~110.86M credits — a ~96M baseline (validate_fees_of_event_v0 PaidFromAddressInputs) plus ~14.85M for the slot-2 TRANSFER key's storage cost. Bumping the headroom to 150M leaves a ~39M buffer for protocol-version drift while staying well clear of bank-budget concerns. Unblocks every helper-using test that was previously panicking at line 75 of id_007 (and friends) on a residual-too-small registration failure. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: Claudius the Magnificent --- .../tests/e2e/framework/mod.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/rs-platform-wallet/tests/e2e/framework/mod.rs b/packages/rs-platform-wallet/tests/e2e/framework/mod.rs index 74351b4607..be07e4fe46 100644 --- a/packages/rs-platform-wallet/tests/e2e/framework/mod.rs +++ b/packages/rs-platform-wallet/tests/e2e/framework/mod.rs @@ -205,13 +205,16 @@ pub async fn setup_with_n_identities( // same destination. We fund + observe before registration so // `register_from_addresses` finds the credits already // committed to platform. - // After Option C (PR #3579), bank.fund_address delivers exactly - // the requested amount. The chain charges the IdentityCreateFromAddresses - // dynamic fee (~96M, validate_fees_of_event_v0 PaidFromAddressInputs) - // from the address residual after registration consumes `funding_per`. - // Fund each address with `funding_per + 100_000_000` so the residual - // (100M) covers the dynamic fee with 4M buffer. - const REGISTRATION_HEADROOM: u64 = 100_000_000; + // + // bank.fund_address delivers exactly the requested amount; the chain + // then charges the `IdentityCreateFromAddresses` dynamic fee from + // the address residual after registration consumes `funding_per`. + // The current testnet dynamic fee is ~110.86M credits — a ~96M + // baseline (validate_fees_of_event_v0 PaidFromAddressInputs) plus + // ~14.85M for the slot-2 TRANSFER key's storage cost. Fund each + // address with `funding_per + 150M` so the residual (150M) covers + // the dynamic fee with ~39M buffer for protocol-version drift. + const REGISTRATION_HEADROOM: u64 = 150_000_000; for identity_index in 0..n { let funding_addr = base.test_wallet.next_unused_address().await?; From 75619fdd1245d825daa160b50c2dd1b7e9e99f16 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 5 May 2026 14:21:28 +0200 Subject: [PATCH 06/34] =?UTF-8?q?feat(rs-platform-wallet/e2e):=20implement?= =?UTF-8?q?=20CR-003=20=E2=80=94=20BankWallet::send=5Fcore=5Fto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the BankWallet::send_core_to unimplemented! stub with a real implementation that builds, signs, and broadcasts a Layer-1 Core transaction from the bank's BIP-44 account 0 via CoreWallet::send_to_addresses (which delegates to the SpvBroadcaster already wired during framework init). Key details: - Serialises in-process on the existing FUNDING_MUTEX so concurrent Core/Platform funding flows can't race UTXO selection or change- address derivation. - Pre-flight balance check returns FrameworkError::Bank with an operator-actionable "top up at " pointer when the bank's confirmed Core balance is below `duffs + 10_000` (a generous fee reserve floor that only gates the pre-check; the wallet's coin selector picks the actual fee). - Surfaces two new helpers — `core_balance_confirmed()` and `primary_core_receive_address()` — used by the harness pre-flight log and by ID-007's diagnostic surface. - Harness now logs the bank's Core balance + primary Core receive address once at framework init under the `platform_wallet::e2e::bank` target, so operators can see at a glance whether the bank is Core-funded and where to send testnet duffs if it isn't. Most tests don't need duffs, so a zero balance is not a hard failure — only CR-/ID-007-class cases trip the under-funded path. This unblocks ID-007 at the framework level. End-to-end runs still require the bank's Core address to be funded on testnet (the address prints during init); once funded, the test runs through and pins the current contract that DIP-9 identity-auth addresses are NOT in monitored_addresses(). Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: Claudius the Magnificent --- .../tests/e2e/framework/bank.rs | 114 +++++++++++++++--- .../tests/e2e/framework/harness.rs | 23 ++++ 2 files changed, 119 insertions(+), 18 deletions(-) diff --git a/packages/rs-platform-wallet/tests/e2e/framework/bank.rs b/packages/rs-platform-wallet/tests/e2e/framework/bank.rs index 599e5ad472..1f4f91d20b 100644 --- a/packages/rs-platform-wallet/tests/e2e/framework/bank.rs +++ b/packages/rs-platform-wallet/tests/e2e/framework/bank.rs @@ -17,6 +17,7 @@ use dpp::address_funds::PlatformAddress; use dpp::fee::Credits; use dpp::util::hash::ripemd160_sha256; use dpp::version::PlatformVersion; +use key_wallet::account::account_type::StandardAccountType; use key_wallet::{AccountType, ChildNumber, Network}; use parking_lot::Mutex as SyncMutex; use platform_wallet::wallet::persister::NoPlatformPersistence; @@ -364,31 +365,108 @@ impl BankWallet { drain_funding_mutex_history() } - /// Send `duffs` of Layer-1 Core duffs from the bank to a Core - /// `dashcore::Address`. Stubbed `unimplemented!()` — the bank - /// today holds Platform credits, not Core coins (see CR-003's - /// "Core-funded bank wallet helper" prerequisite). Wired in when - /// Task #15 (SPV runtime) lands and the bank gains a Core-funded - /// account. + /// Bank's confirmed Core (Layer-1) balance in duffs, sourced from + /// the lock-free atomic updated by SPV. Used for pre-flight under- + /// funded checks in [`Self::send_core_to`] and the harness init + /// log; not transactionally consistent with the wallet's UTXO set. + pub fn core_balance_confirmed(&self) -> u64 { + self.wallet.balance().confirmed() + } + + /// First BIP-44 (Core) receive address. Stable across process + /// runs while the address remains unused — once a UTXO lands on + /// it the pool advances and a subsequent call returns the next + /// index. Surfaced in the harness init log so the operator can + /// see where to send Layer-1 duffs to fund the bank. + pub async fn primary_core_receive_address(&self) -> FrameworkResult { + self.wallet + .core() + .next_receive_address_for_account(0) + .await + .map_err(wallet_err) + } + + /// Send `duffs` of Layer-1 Core duffs from the bank's BIP-44 + /// account 0 to a Core `dashcore::Address`. + /// + /// Builds, signs, and broadcasts via [`SpvBroadcaster`] using + /// [`CoreWallet::send_to_addresses`]. Serialises in-process on + /// [`FUNDING_MUTEX`] so concurrent Core / Platform funding flows + /// don't race UTXO selection. Returns the broadcast `Txid` on + /// success; does NOT wait for instant-lock or chain confirmation + /// — callers follow up with [`super::wait::wait_for_core_balance`] + /// when they need positive-balance arrival. + /// + /// Errors: + /// - [`FrameworkError::Bank`] when the bank's confirmed Core + /// balance is below `duffs + CORE_TX_FEE_RESERVE`. The error + /// message names the bank's primary receive address so the + /// operator knows where to top up testnet duffs. + /// - [`FrameworkError::Wallet`] for build/sign/broadcast failures + /// surfaced from the underlying `CoreWallet`. /// - /// Used by `ID-007` to attempt a Layer-1 send to a DIP-9 - /// identity-auth address; the assertion side of that test - /// pins "the Core balance does NOT increase" against the - /// pinned `key-wallet` revision's contract. + /// Used by `ID-007` (negative contract: identity-auth addresses + /// are NOT in `monitored_addresses()`, so the wallet's Core + /// balance must NOT observe this send within the test's window). pub async fn send_core_to( &self, target: &dashcore::Address, duffs: u64, ) -> FrameworkResult { - let _ = (target, duffs); - unimplemented!( - "BankWallet::send_core_to — CR-003 prerequisite. The bank \ - today holds Platform credits via DIP-17 platform-payment \ - accounts, not Core duffs on a DIP-9 / BIP-44 receive \ - account. Wire through when Task #15 (SPV runtime) lands \ - and the bank exposes a Core-funded account; see TEST_SPEC.md \ - § ID-007 / § CR-003 for the gating discussion." + // Match `fund_address`'s in-process serialisation so a Core + // send running alongside platform funding doesn't share-pick + // UTXOs / change addresses with a concurrent build. + let _guard = FUNDING_MUTEX.lock().await; + + // Generous standard-tx fee reserve (~0.0001 DASH at 1 sat/B + // for a typical 1-input-2-output tx). The wallet's coin + // selector picks the actual fee from its config; this floor + // only gates the "is there enough to even try" pre-check so + // failures point operators at the funding address, not at a + // builder error two layers deep. + const CORE_TX_FEE_RESERVE: u64 = 10_000; + + let confirmed = self.wallet.balance().confirmed(); + let required = duffs.saturating_add(CORE_TX_FEE_RESERVE); + if confirmed < required { + // Surface the operator-actionable pointer same shape as + // the `BankWallet::load` under-funded panic — same + // documented format every other framework caller relies on. + let receive_addr = self + .wallet + .core() + .next_receive_address_for_account(0) + .await + .map_err(wallet_err)?; + return Err(FrameworkError::Bank(format!( + "Bank Core under-funded.\n \ + confirmed: {confirmed} duffs\n \ + required : {required} duffs (send {duffs} + ~{CORE_TX_FEE_RESERVE} fee reserve)\n \ + short by : {short} duffs\n \ + top up at: {receive_addr}\n\ + \n\ + Send testnet Core duffs to the address above, then re-run the test.", + short = required - confirmed, + ))); + } + + let outputs = vec![(target.clone(), duffs)]; + let tx = self + .wallet + .core() + .send_to_addresses(StandardAccountType::BIP44Account, 0, outputs) + .await + .map_err(wallet_err)?; + + let txid = tx.txid(); + tracing::info!( + target: "platform_wallet::e2e::bank", + %txid, + target = %target, + duffs, + "bank.send_core_to broadcast" ); + Ok(txid) } } diff --git a/packages/rs-platform-wallet/tests/e2e/framework/harness.rs b/packages/rs-platform-wallet/tests/e2e/framework/harness.rs index bbffd879b0..92ac50707a 100644 --- a/packages/rs-platform-wallet/tests/e2e/framework/harness.rs +++ b/packages/rs-platform-wallet/tests/e2e/framework/harness.rs @@ -157,6 +157,29 @@ impl E2eContext { // Panics on under-funded balance — see `BankWallet::load`. let bank = BankWallet::load(&manager, &config).await?; + // Surface the bank's Core (Layer-1) balance and primary + // receive address at init. Most tests don't need duffs, so a + // zero balance is not fatal — the operator only needs to act + // when a CR-/ID-007-class case actually calls `send_core_to`. + // Logged once per process to make funding the bank a + // single-line task. Errors fetching the address are demoted + // to a warning so framework init isn't gated on Core paths + // that most tests bypass entirely. + match bank.primary_core_receive_address().await { + Ok(addr) => tracing::info!( + target: "platform_wallet::e2e::bank", + core_balance_duffs = bank.core_balance_confirmed(), + core_address = %addr, + "Bank Core (Layer-1) status" + ), + Err(err) => tracing::warn!( + target: "platform_wallet::e2e::bank", + error = %err, + core_balance_duffs = bank.core_balance_confirmed(), + "Bank Core address derivation failed; pre-flight log incomplete" + ), + } + // Resolve / register the bank identity BEFORE the orphan // sweep so [`cleanup::sweep_orphans`] has a valid sweep // destination on its very first invocation. From 75ba17be158f99e71b33840700735346fd1f4eb1 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 5 May 2026 14:21:41 +0200 Subject: [PATCH 07/34] docs(rs-platform-wallet/e2e): mark ID-007 status FRAMEWORK-READY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Framework prerequisites for ID-007 are now resolved: SPV runtime is live (Task #15) and BankWallet::send_core_to is implemented (CR-003). The test runs end-to-end up to the point where it tries to send Core duffs from the bank — gated only on operator pre-funding the bank's BIP-44 account 0 receive address with at least `CORE_SEND_DUFFS + ~fee` testnet duffs. Updates: - TEST_SPEC.md ID-007 entry: status flipped from BLOCKED to FRAMEWORK-READY with the new operator-funding gate documented. - Test #[ignore] reason: same flip — points operators at the framework init log line where the Core receive address prints. - Module-level doc comment + step 4 inline comment: no longer claims send_core_to is unimplemented; describes the current contract pinning rationale for the negative wait_for_core_balance window. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: Claudius the Magnificent --- .../rs-platform-wallet/tests/e2e/TEST_SPEC.md | 2 +- ...7_identity_auth_addresses_not_monitored.rs | 37 ++++++++++++------- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md b/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md index 30e80d95dc..62ceb8ec88 100644 --- a/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md +++ b/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md @@ -895,7 +895,7 @@ Counts by priority: **P0: 10**, **P1: 24** (incl. 2 post-Task #15), **P2: 57** ( #### ID-007 — Identity-auth addresses are visible to SPV monitor (BLOCKED on Task #15) - **Priority**: P2 -- **Status**: BLOCKED — full test body implemented, gated behind `#[ignore]`. Will fail loudly the first time it's invoked under `--ignored` until: (1) SPV runtime is re-enabled (Task #15 — same gate as `CR-001`/`CR-002`/`CR-003`), (2) the Core-funded bank wallet helper lands (CR-003 prerequisite — current bank holds Platform credits, not Core duffs), (3) the framework's `bank.send_core_to(..)` helper is wired (currently stubbed with `unimplemented!()`). When all three exist, drop the `#[ignore]` to the standard "needs testnet" form and the test runs end-to-end. Tracks the scenario from closed PR `dashpay/rust-dashcore#554` (the parked attempt to ship `BlockchainIdentities*` AccountType variants and flip `WalletAccountCreationOptions::Default` to monitor those addresses) and DET follow-up issue `dash-evo-tool#692`. The wallet's contract today is "identity-auth addresses are NOT monitored"; this case pins that contract so any reshape upstream surfaces here rather than silently in DET or in user funds. +- **Status**: FRAMEWORK-READY — full test body implemented; `#[ignore]`-tagged. Framework prerequisites cleared: SPV runtime is live (Task #15 landed) and `BankWallet::send_core_to` is implemented (CR-003 — uses `CoreWallet::send_to_addresses` against the bank's BIP-44 account 0). End-to-end runs are gated on **operator pre-funding the bank's Core (Layer-1) receive address** with at least `100_000 + fee` duffs of testnet DASH. The address is logged at framework init (`platform_wallet::e2e::bank` target, `Bank Core (Layer-1) status core_balance_duffs core_address`); the same address surfaces in the `FrameworkError::Bank` "Bank Core under-funded" message if `send_core_to` is invoked with a zero balance. Tracks the scenario from closed PR `dashpay/rust-dashcore#554` (the parked attempt to ship `BlockchainIdentities*` AccountType variants and flip `WalletAccountCreationOptions::Default` to monitor those addresses) and DET follow-up issue `dash-evo-tool#692`. The wallet's contract today is "identity-auth addresses are NOT monitored"; this case pins that contract so any reshape upstream surfaces here rather than silently in DET or in user funds. - **Wallet feature exercised**: `PlatformWalletInfo::monitored_addresses` (`wallet/platform_wallet_traits.rs:93`) projection for DIP-9 identity-authentication addresses derived via `derive_ecdsa_identity_auth_keypair_from_master` (`wallet/identity/network/identity_handle.rs:143`). Concretely: the `m/9'/coinType'/5'/0'/identity_index'/key_index'` subfeature path, which is not in `WalletAccountCreationOptions::Default` at the pinned `key-wallet` revision. - **DET parallel**: `dash-evo-tool#692` (the follow-up issue PR `dashpay/rust-dashcore#554` referenced for the DET-side `spv_account_metadata()` match arm). - **Preconditions**: diff --git a/packages/rs-platform-wallet/tests/e2e/cases/id_007_identity_auth_addresses_not_monitored.rs b/packages/rs-platform-wallet/tests/e2e/cases/id_007_identity_auth_addresses_not_monitored.rs index 9ccab5be73..644bc198cd 100644 --- a/packages/rs-platform-wallet/tests/e2e/cases/id_007_identity_auth_addresses_not_monitored.rs +++ b/packages/rs-platform-wallet/tests/e2e/cases/id_007_identity_auth_addresses_not_monitored.rs @@ -1,12 +1,16 @@ //! ID-007 — Identity-auth addresses are NOT visible to SPV monitor. //! //! Spec: `tests/e2e/TEST_SPEC.md` (### Identity (ID) → ID-007). -//! Pinned status: BLOCKED — full test body implemented, gated behind -//! `#[ignore]`. Tracks closed PR `dashpay/rust-dashcore#554` (the -//! parked attempt to add `BlockchainIdentities*` `AccountType` -//! variants and flip `WalletAccountCreationOptions::Default` to -//! monitor those addresses) and DET follow-up issue -//! `dash-evo-tool#692`. +//! Pinned status: FRAMEWORK-READY — full test body implemented, +//! `#[ignore]`-tagged. SPV runtime is live (Task #15) and the bank's +//! `send_core_to` helper is wired (CR-003). End-to-end runs need the +//! bank's Core (Layer-1) receive address to be pre-funded on testnet; +//! the address is logged at framework init under target +//! `platform_wallet::e2e::bank`. Tracks closed PR +//! `dashpay/rust-dashcore#554` (the parked attempt to add +//! `BlockchainIdentities*` `AccountType` variants and flip +//! `WalletAccountCreationOptions::Default` to monitor those +//! addresses) and DET follow-up issue `dash-evo-tool#692`. //! //! Pins the CURRENT contract: //! - identity-auth addresses derived via @@ -52,10 +56,14 @@ const CORE_SEND_DUFFS: u64 = 100_000; /// contract. Marvin's spec uses 30 seconds; matched here. const CORE_BALANCE_NEGATIVE_WINDOW: Duration = Duration::from_secs(30); -#[ignore = "ID-007 — BLOCKED on Task #15 (SPV runtime) + Core-funded bank \ - helper (CR-003 prerequisite). Pins the contract that DIP-9 \ - identity-auth addresses are NOT in monitored_addresses(). \ - Tracks closed PR dashpay/rust-dashcore#554."] +#[ignore = "ID-007 — needs testnet + bank Core (Layer-1) pre-funding. \ + Framework gates cleared: SPV runtime live (Task #15) and \ + BankWallet::send_core_to implemented (CR-003). End-to-end \ + run requires operator-funded bank Core receive address \ + (logged at framework init under platform_wallet::e2e::bank \ + target). Pins the contract that DIP-9 identity-auth \ + addresses are NOT in monitored_addresses(). Tracks closed \ + PR dashpay/rust-dashcore#554."] #[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] async fn id_007_identity_auth_addresses_not_monitored() { let _ = tracing_subscriber::fmt() @@ -134,10 +142,11 @@ async fn id_007_identity_auth_addresses_not_monitored() { ); // Step 4: send `CORE_SEND_DUFFS` from the bank to `auth_addr_zero` - // on Layer-1. Today this is `unimplemented!()` — see - // `BankWallet::send_core_to`. When Task #15 + the Core-funded - // bank helper land, replace the stub with a real broadcast and - // wait for the instant-lock event. + // on Layer-1 via `BankWallet::send_core_to` (CR-003). Returns a + // broadcast `Txid`; we don't wait for instant-lock because the + // negative contract is "the wallet's monitored set never sees + // this". The `wait_for_core_balance` call below is what bounds + // observation of the (expected absent) UTXO. let pre_balance = s .base .test_wallet From 458ee807d77bad3bb67fe031a8b73331d0d1eafb Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 5 May 2026 14:24:58 +0200 Subject: [PATCH 08/34] refactor(rs-platform-wallet/e2e): factor core_send free fn + prominent bank Core log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two extensions to the CR-003 BankWallet::send_core_to landing: 1. Factor the actual Core-broadcast body into a free function `core_send(wallet, target, duffs)` in `framework/bank.rs`. The bank's send_core_to is now a thin wrapper that adds the FUNDING_MUTEX guard, the under-funded pre-check (with the bank's own receive address in the error), and the broadcast-info log; the free function is what the upcoming test-wallet Core sweep in cleanup.rs reuses so we have one Core-broadcast surface across the framework. 2. Promote the bank's Core (Layer-1) status log at framework init to a prominent, copy-pasteable line: ═══ BANK CORE ADDRESS (fund here for CR-* / ID-007 tests) ═══ Most tests don't need duffs, so a zero balance is not fatal — but when a CR-/ID-007-class case runs, the operator now has a single visually-distinct line in the test output naming the address to top up. Field names switched to `bank_core_addr` / `bank_core_balance` for parity with the under-funded error message. CORE_TX_FEE_RESERVE is hoisted from a per-fn const to a `pub const` on the bank module so the cleanup-side sweep can share the same fee reserve floor. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: Claudius the Magnificent --- .../tests/e2e/framework/bank.rs | 73 ++++++++++++------- .../tests/e2e/framework/harness.rs | 24 +++--- 2 files changed, 58 insertions(+), 39 deletions(-) diff --git a/packages/rs-platform-wallet/tests/e2e/framework/bank.rs b/packages/rs-platform-wallet/tests/e2e/framework/bank.rs index 1f4f91d20b..bfa8036a81 100644 --- a/packages/rs-platform-wallet/tests/e2e/framework/bank.rs +++ b/packages/rs-platform-wallet/tests/e2e/framework/bank.rs @@ -389,13 +389,16 @@ impl BankWallet { /// Send `duffs` of Layer-1 Core duffs from the bank's BIP-44 /// account 0 to a Core `dashcore::Address`. /// - /// Builds, signs, and broadcasts via [`SpvBroadcaster`] using - /// [`CoreWallet::send_to_addresses`]. Serialises in-process on + /// Thin wrapper over [`core_send`]: serialises on /// [`FUNDING_MUTEX`] so concurrent Core / Platform funding flows - /// don't race UTXO selection. Returns the broadcast `Txid` on - /// success; does NOT wait for instant-lock or chain confirmation - /// — callers follow up with [`super::wait::wait_for_core_balance`] - /// when they need positive-balance arrival. + /// don't race UTXO selection, runs an under-funded pre-check + /// against the bank's confirmed Core balance, and adds the bank's + /// primary receive address to the error message so operators + /// know where to top up testnet duffs. Returns the broadcast + /// `Txid` on success; does NOT wait for instant-lock or chain + /// confirmation — callers follow up with + /// [`super::wait::wait_for_core_balance`] when they need + /// positive-balance arrival. /// /// Errors: /// - [`FrameworkError::Bank`] when the bank's confirmed Core @@ -413,25 +416,15 @@ impl BankWallet { target: &dashcore::Address, duffs: u64, ) -> FrameworkResult { - // Match `fund_address`'s in-process serialisation so a Core - // send running alongside platform funding doesn't share-pick - // UTXOs / change addresses with a concurrent build. let _guard = FUNDING_MUTEX.lock().await; - // Generous standard-tx fee reserve (~0.0001 DASH at 1 sat/B - // for a typical 1-input-2-output tx). The wallet's coin - // selector picks the actual fee from its config; this floor - // only gates the "is there enough to even try" pre-check so - // failures point operators at the funding address, not at a - // builder error two layers deep. - const CORE_TX_FEE_RESERVE: u64 = 10_000; - let confirmed = self.wallet.balance().confirmed(); let required = duffs.saturating_add(CORE_TX_FEE_RESERVE); if confirmed < required { // Surface the operator-actionable pointer same shape as - // the `BankWallet::load` under-funded panic — same - // documented format every other framework caller relies on. + // the `BankWallet::load` under-funded panic so operators + // hit the same documented format whether the bank is + // Platform-credit or Core-duff under-funded. let receive_addr = self .wallet .core() @@ -450,15 +443,7 @@ impl BankWallet { ))); } - let outputs = vec![(target.clone(), duffs)]; - let tx = self - .wallet - .core() - .send_to_addresses(StandardAccountType::BIP44Account, 0, outputs) - .await - .map_err(wallet_err)?; - - let txid = tx.txid(); + let txid = core_send(&self.wallet, target, duffs).await?; tracing::info!( target: "platform_wallet::e2e::bank", %txid, @@ -474,6 +459,38 @@ fn wallet_err(err: PlatformWalletError) -> FrameworkError { FrameworkError::Wallet(err.to_string()) } +/// Generous standard-tx fee reserve (~0.0001 DASH at 1 sat/B for a +/// typical 1-input-2-output tx). The wallet's coin selector picks the +/// actual fee from its config; this floor only gates the "is there +/// enough to even try" pre-check on `BankWallet::send_core_to` and +/// the dust-floor on the test-wallet Core sweep. +pub const CORE_TX_FEE_RESERVE: u64 = 10_000; + +/// Build, sign, and broadcast a Core (Layer-1) transaction sending +/// `duffs` from `wallet`'s BIP-44 account 0 to `target`. +/// +/// Free function so both [`BankWallet::send_core_to`] and the +/// `cleanup::sweep_core_addresses` test-wallet sweep can share the +/// actual broadcast path. Callers are responsible for their own +/// pre-flight checks (under-funded balance, lock serialisation) and +/// for selecting the appropriate `duffs` amount — this helper does +/// nothing more than translate the inputs into a +/// [`CoreWallet::send_to_addresses`] call and surface the resulting +/// `Txid`. +pub(super) async fn core_send( + wallet: &Arc, + target: &dashcore::Address, + duffs: u64, +) -> FrameworkResult { + let outputs = vec![(target.clone(), duffs)]; + let tx = wallet + .core() + .send_to_addresses(StandardAccountType::BIP44Account, 0, outputs) + .await + .map_err(wallet_err)?; + Ok(tx.txid()) +} + /// Derive the DIP-17 platform-payment address at `index` from the /// already-loaded `PlatformWallet`, using path /// `m/9'/coin_type'/17'/account'/key_class'/index`. diff --git a/packages/rs-platform-wallet/tests/e2e/framework/harness.rs b/packages/rs-platform-wallet/tests/e2e/framework/harness.rs index 92ac50707a..fe2cd3967c 100644 --- a/packages/rs-platform-wallet/tests/e2e/framework/harness.rs +++ b/packages/rs-platform-wallet/tests/e2e/framework/harness.rs @@ -158,24 +158,26 @@ impl E2eContext { let bank = BankWallet::load(&manager, &config).await?; // Surface the bank's Core (Layer-1) balance and primary - // receive address at init. Most tests don't need duffs, so a - // zero balance is not fatal — the operator only needs to act - // when a CR-/ID-007-class case actually calls `send_core_to`. - // Logged once per process to make funding the bank a - // single-line task. Errors fetching the address are demoted - // to a warning so framework init isn't gated on Core paths - // that most tests bypass entirely. + // receive address at init with a visual marker so it's easy + // to spot in test output. Most tests don't need duffs — a + // zero balance is not fatal — but CR-/ID-007-class cases + // require the address to be pre-funded with testnet duffs + // before they can run end-to-end. Logged once per process so + // funding the bank is a single-line copy-paste task. Errors + // fetching the address are demoted to a warning so framework + // init isn't gated on Core paths that most tests bypass + // entirely. match bank.primary_core_receive_address().await { Ok(addr) => tracing::info!( target: "platform_wallet::e2e::bank", - core_balance_duffs = bank.core_balance_confirmed(), - core_address = %addr, - "Bank Core (Layer-1) status" + bank_core_addr = %addr, + bank_core_balance = bank.core_balance_confirmed(), + "═══ BANK CORE ADDRESS (fund here for CR-* / ID-007 tests) ═══" ), Err(err) => tracing::warn!( target: "platform_wallet::e2e::bank", error = %err, - core_balance_duffs = bank.core_balance_confirmed(), + bank_core_balance = bank.core_balance_confirmed(), "Bank Core address derivation failed; pre-flight log incomplete" ), } From 9df54939e8eee5fce4633174d9e1a2c3dea5290f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 5 May 2026 14:25:15 +0200 Subject: [PATCH 09/34] feat(rs-platform-wallet/e2e): wire Core-side cleanup sweep to bank MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends `cleanup::teardown_one` and `cleanup::sweep_orphans` with a real Core (Layer-1) sweep — counterpart to the existing platform- credit / identity-credit sweeps. Recovers Core duffs on every test wallet's BIP-44 account 0 back to the bank's primary BIP-44 receive address. Mechanics: - `sweep_core_addresses` (no longer a no-op stub) reads the wallet's lock-free confirmed Core balance, gates on a `CORE_SWEEP_DUST_FLOOR` of 100_000 duffs (so we never burn most of a balance to fee), and delegates the actual broadcast to `bank::core_send` — the same free function the bank's send_core_to wraps. Failures are logged at WARN and propagated; the orphan-recovery loop catches them and retains the registry entry for next-run retry. - `TestWallet` gains two thin helpers — `core_balance_confirmed()` and `sweep_core_to(target, amount)` — that mirror the bank's surface for individual tests that want to broadcast a Core send without going through teardown. Most tests don't need them; they exist for completeness and for cases where the test body itself needs to trigger the sweep path explicitly. For ID-007 specifically, the test wallet's Core balance stays at 0 under the negative contract (auth_addr is not in the bloom filter), so the sweep is a no-op there — `core sweep: balance at or below dust floor; nothing to sweep`. That's correct behaviour, not a bug. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: Claudius the Magnificent --- .../tests/e2e/framework/cleanup.rs | 83 +++++++++++++++++-- .../tests/e2e/framework/wallet_factory.rs | 26 ++++++ 2 files changed, 101 insertions(+), 8 deletions(-) diff --git a/packages/rs-platform-wallet/tests/e2e/framework/cleanup.rs b/packages/rs-platform-wallet/tests/e2e/framework/cleanup.rs index 1739e2f67d..f83e6c9291 100644 --- a/packages/rs-platform-wallet/tests/e2e/framework/cleanup.rs +++ b/packages/rs-platform-wallet/tests/e2e/framework/cleanup.rs @@ -22,7 +22,7 @@ use platform_wallet::{PlatformWallet, PlatformWalletError, PlatformWalletManager use super::signer::SeedBackedIdentitySigner; -use super::bank::BankWallet; +use super::bank::{core_send, BankWallet, CORE_TX_FEE_RESERVE}; use super::bank_identity::BankIdentity; use super::registry::{EntryStatus, PersistentTestWalletRegistry, RegistryEntry, WalletSeedHash}; use super::wallet_factory::TestWallet; @@ -138,7 +138,7 @@ async fn sweep_one( ); } sweep_identities_with_seed(&wallet, &seed_bytes, network, bank_identity).await?; - sweep_core_addresses(&wallet).await?; + sweep_core_addresses(&wallet, bank).await?; sweep_unused_core_asset_locks(&wallet).await?; sweep_shielded(&wallet).await?; @@ -191,7 +191,7 @@ pub async fn teardown_one( bank_identity, ) .await?; - sweep_core_addresses(test_wallet.platform_wallet()).await?; + sweep_core_addresses(test_wallet.platform_wallet(), bank).await?; sweep_unused_core_asset_locks(test_wallet.platform_wallet()).await?; sweep_shielded(test_wallet.platform_wallet()).await?; @@ -530,14 +530,81 @@ const IDENTITY_SWEEP_FLOOR: Credits = 50_000_000; /// exceed the chain-time fee. Empirically ~12-15M on testnet. const IDENTITY_SWEEP_FEE_RESERVE: Credits = 30_000_000; -/// Drain core (Layer 1) UTXOs to the bank's core address. Noop until -/// the SPV wallet runtime is back online in this harness. -// TODO(rs-platform-wallet/e2e #core-sweep): implement once the SPV -// runtime (Task #15) lets us sign and broadcast core transactions. -async fn sweep_core_addresses(_wallet: &Arc) -> FrameworkResult<()> { +/// Drain Core (Layer-1) UTXOs to the bank's primary BIP-44 receive +/// address. No-op when the wallet's confirmed Core balance is at or +/// below [`CORE_SWEEP_DUST_FLOOR`] — sweeping below the floor would +/// either burn the entire balance to the chain fee or fail the +/// builder's coin-selection step. +/// +/// Best-effort: failures (no funded address, builder error, broadcast +/// rejection) are logged at WARN and surfaced as +/// [`FrameworkError::Wallet`]. The orphan-recovery loop in +/// [`sweep_orphans`] catches that and keeps the registry entry for a +/// later retry. +async fn sweep_core_addresses( + wallet: &Arc, + bank: &BankWallet, +) -> FrameworkResult<()> { + let confirmed = wallet.balance().confirmed(); + if confirmed <= CORE_SWEEP_DUST_FLOOR { + tracing::debug!( + target: "platform_wallet::e2e::cleanup", + wallet_id = %hex::encode(wallet.wallet_id()), + confirmed, + floor = CORE_SWEEP_DUST_FLOOR, + "core sweep: balance at or below dust floor; nothing to sweep" + ); + return Ok(()); + } + + let amount = confirmed.saturating_sub(CORE_TX_FEE_RESERVE); + if amount == 0 { + tracing::debug!( + target: "platform_wallet::e2e::cleanup", + wallet_id = %hex::encode(wallet.wallet_id()), + confirmed, + "core sweep: balance covers fee reserve only; skipping" + ); + return Ok(()); + } + + // Resolve the bank's primary Core receive address — same address + // surfaced in the harness pre-flight log so swept funds land at + // the operator-known location. + let bank_core_addr = bank.primary_core_receive_address().await?; + + match core_send(wallet, &bank_core_addr, amount).await { + Ok(txid) => { + tracing::info!( + target: "platform_wallet::e2e::cleanup", + wallet_id = %hex::encode(wallet.wallet_id()), + %txid, + amount, + bank_core_addr = %bank_core_addr, + "core sweep: drained Core duffs to bank" + ); + } + Err(err) => { + tracing::warn!( + target: "platform_wallet::e2e::cleanup", + wallet_id = %hex::encode(wallet.wallet_id()), + amount, + error = %err, + "core sweep: broadcast failed; entry retained" + ); + return Err(err); + } + } Ok(()) } +/// Below this confirmed balance the Core sweep refuses to broadcast. +/// Sized to comfortably exceed the [`CORE_TX_FEE_RESERVE`] floor so +/// the post-fee residual is always non-trivial — sweeping a balance +/// of e.g. 1.5x the fee reserve burns most of the value as fee and +/// the recovered amount is meaningless. +const CORE_SWEEP_DUST_FLOOR: u64 = 100_000; + /// Consume unspent asset-lock outputs and refund their credits to the /// bank. Noop until the asset-lock harness is wired up. // TODO(rs-platform-wallet/e2e #asset-lock-sweep): walk the wallet's diff --git a/packages/rs-platform-wallet/tests/e2e/framework/wallet_factory.rs b/packages/rs-platform-wallet/tests/e2e/framework/wallet_factory.rs index 3701d42fd4..9376d10e66 100644 --- a/packages/rs-platform-wallet/tests/e2e/framework/wallet_factory.rs +++ b/packages/rs-platform-wallet/tests/e2e/framework/wallet_factory.rs @@ -199,6 +199,32 @@ impl TestWallet { self.wallet.platform().total_credits().await } + /// Lock-free Core (Layer-1) confirmed balance in duffs, sourced + /// from the atomic updated by SPV. Test helper — not + /// transactionally consistent with the wallet's UTXO set. + pub fn core_balance_confirmed(&self) -> u64 { + self.wallet.balance().confirmed() + } + + /// Sweep `amount` Core duffs from this wallet's BIP-44 account 0 + /// to `target`. Thin wrapper over [`super::bank::core_send`] — + /// builds, signs, and broadcasts via [`SpvBroadcaster`]. Returns + /// the broadcast `Txid`. + /// + /// Test-only helper: the framework's + /// [`super::cleanup::teardown_one`] / `sweep_orphans` paths + /// already drain Core funds through the same `core_send` free + /// function, so individual tests rarely need this directly. Add + /// it for cases that explicitly want to broadcast a Core send + /// from a test wallet without going through teardown. + pub async fn sweep_core_to( + &self, + target: &dashcore::Address, + amount: u64, + ) -> FrameworkResult { + super::bank::core_send(&self.wallet, target, amount).await + } + /// Transfer credits to one or more outputs. Auto-selects inputs /// from the default account and uses [`default_fee_strategy`] /// (reduce output #0). `outputs` maps each recipient address From b856fa889a353ff1af8246a83094f529d0ce8161 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 5 May 2026 14:50:25 +0200 Subject: [PATCH 10/34] feat(rs-platform-wallet/e2e): add setup_with_core_funded_test_wallet helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands `setup_with_core_funded_test_wallet(duffs)` next to `setup_with_n_identities` for cases that need an asset-lock-buildable balance on the test wallet's own Core (Layer-1) side. Composes the existing `setup` → `bank.send_core_to` → `wait_for_core_balance` chain into a single guard so CR-003 (and any future Core-funded case) doesn't re-derive the boilerplate. Surfaces `FrameworkError::Bank` verbatim from `BankWallet::send_core_to` so the operator-actionable "top up at " message reaches the test log unchanged on bank under-funding. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tests/e2e/framework/mod.rs | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/packages/rs-platform-wallet/tests/e2e/framework/mod.rs b/packages/rs-platform-wallet/tests/e2e/framework/mod.rs index be07e4fe46..0a2a57db51 100644 --- a/packages/rs-platform-wallet/tests/e2e/framework/mod.rs +++ b/packages/rs-platform-wallet/tests/e2e/framework/mod.rs @@ -250,6 +250,89 @@ pub async fn setup_with_n_identities( Ok(MultiIdentitySetupGuard { base, identities }) } +/// Set up a fresh test wallet pre-funded with Core (Layer-1) duffs +/// drawn from the bank's BIP-44 account 0. +/// +/// Companion to [`setup`] / [`setup_with_n_identities`] for cases that +/// need an asset-lock-buildable balance on the test wallet's own Core +/// side — `CR-003` is the canonical caller. The flow: +/// +/// 1. Build a fresh test wallet via [`setup`]. +/// 2. Derive the test wallet's first Core receive address on BIP-44 +/// account 0 via [`platform_wallet::wallet::core::CoreWallet::next_receive_address_for_account`]. +/// 3. Send `duffs` from the bank's Core account to that address using +/// [`super::bank::BankWallet::send_core_to`] (gated on +/// `confirmed >= duffs + CORE_TX_FEE_RESERVE`; under-funded errors +/// surface as [`FrameworkError::Bank`] with the bank's Core receive +/// address embedded). +/// 4. Wait up to [`CORE_FUNDING_TIMEOUT`] for the test wallet's +/// confirmed Core balance (sourced from the SPV-updated atomic via +/// `WalletBalance::confirmed`) to reach `duffs`. +/// +/// On success the test wallet's `core_balance_confirmed()` is +/// guaranteed to be `>= duffs`, so downstream callers (e.g. +/// `IdentityWallet::register_identity_with_funding_external_signer` +/// with `IdentityFundingMethod::FundWithWallet { amount_duffs }`) can +/// build an asset lock without a follow-up Core sync race. +/// +/// Errors: +/// - [`FrameworkError::Bank`] when the bank itself is under-funded — +/// propagated verbatim from [`super::bank::BankWallet::send_core_to`] +/// so the operator-actionable "top up at <addr>" message reaches +/// the test log unchanged. +/// - [`FrameworkError::Wallet`] for any failure deriving the test +/// wallet's Core address. +/// - [`FrameworkError::Cleanup`] (via [`wait::wait_for_core_balance`]) +/// when the SPV bloom filter doesn't observe the inbound UTXO +/// within [`CORE_FUNDING_TIMEOUT`]. +pub async fn setup_with_core_funded_test_wallet(duffs: u64) -> FrameworkResult { + use std::time::Duration; + + use super::framework::wait::wait_for_core_balance; + + let base = setup().await?; + + let core_recv = base + .test_wallet + .platform_wallet() + .core() + .next_receive_address_for_account(0) + .await + .map_err(|err| { + FrameworkError::Wallet(format!( + "setup_with_core_funded_test_wallet: derive test-wallet Core receive \ + address (account=0): {err}" + )) + })?; + + let txid = base.ctx.bank().send_core_to(&core_recv, duffs).await?; + tracing::info!( + target: "platform_wallet::e2e::setup", + %txid, + target_addr = %core_recv, + duffs, + "setup_with_core_funded_test_wallet: bank.send_core_to broadcast" + ); + + // Wait for the SPV bloom filter to observe the inbound UTXO and + // raise the test wallet's confirmed Core balance to at least + // `duffs`. The bank's send is non-blocking — `send_core_to` does + // NOT wait for instant-lock — so `wait_for_core_balance` is what + // gives the caller a positive-arrival signal. + wait_for_core_balance(&base.test_wallet, duffs, CORE_FUNDING_TIMEOUT).await?; + + Ok(base) +} + +/// Default deadline for the test wallet's confirmed Core balance to +/// reach the funding amount in [`setup_with_core_funded_test_wallet`]. +/// 5 minutes mirrors the upper bound on testnet's IS-lock window the +/// asset-lock manager uses internally +/// (`asset_lock::manager::create_funded_asset_lock_proof` waits up to +/// 300 s for a proof) — anything longer is symptomatic of a peer-list +/// or mn-list problem the harness should surface, not paper over. +pub const CORE_FUNDING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300); + /// Guard returned by [`setup_with_n_identities`]. Wraps the base /// [`SetupGuard`] plus the freshly-registered identities. /// From 726cee37ad6e4285384050cadeb695e9ce2e9880 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 5 May 2026 14:50:39 +0200 Subject: [PATCH 11/34] =?UTF-8?q?test(rs-platform-wallet/e2e):=20implement?= =?UTF-8?q?=20CR-003=20=E2=80=94=20asset-lock-funded=20identity=20registra?= =?UTF-8?q?tion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the canonical asset-lock-funded `IdentityCreate` path: `setup_with_core_funded_test_wallet(200M duffs)` → `IdentityWallet::register_identity_with_funding_external_signer` with `IdentityFundingMethod::FundWithWallet { amount_duffs = 100M }`. The wallet internally drives `AssetLockManager::create_funded_asset_lock_proof` (build → broadcast → wait IS / fall back to ChainLock) and submits the `IdentityCreate` transition against the resolved proof. Asserts: - on-chain identity is independently fetchable with balance ≥ half lock amount (deterministic, fee-tolerant lower bound), - slot-0 key on the fetched identity is MASTER+AUTHENTICATION (the protocol's signer-key contract for `IdentityCreate`), - every tracked asset lock landed in `InstantSendLocked` / `ChainLocked` final state (no `Built` / `Broadcast` orphans). Tagged `#[ignore]` for testnet env vars + bank Core funding. Mirrors DET's `test_tc004_create_registration_asset_lock`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../cr_003_asset_lock_funded_registration.rs | 274 ++++++++++++++++++ .../rs-platform-wallet/tests/e2e/cases/mod.rs | 1 + 2 files changed, 275 insertions(+) create mode 100644 packages/rs-platform-wallet/tests/e2e/cases/cr_003_asset_lock_funded_registration.rs diff --git a/packages/rs-platform-wallet/tests/e2e/cases/cr_003_asset_lock_funded_registration.rs b/packages/rs-platform-wallet/tests/e2e/cases/cr_003_asset_lock_funded_registration.rs new file mode 100644 index 0000000000..0ec5f5d2d8 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/cr_003_asset_lock_funded_registration.rs @@ -0,0 +1,274 @@ +//! CR-003 — Asset-lock-funded identity registration (full path). +//! +//! Spec: `tests/e2e/TEST_SPEC.md` (### Core (CR) → CR-003). +//! Pinned status: STUB — full test body implemented, `#[ignore]`-tagged +//! behind testnet env vars + bank Core funding. SPV runtime is live +//! (Task #15) and `BankWallet::send_core_to` is wired (CR-003 +//! prerequisite that landed with `ID-007`). The remaining gating is a +//! pre-funded bank Core (Layer-1) receive address on testnet — the +//! address is logged at framework init under target +//! `platform_wallet::e2e::bank` and embedded in the +//! `FrameworkError::Bank` "Bank Core under-funded" message that +//! `setup_with_core_funded_test_wallet` surfaces when the floor isn't +//! met. End-to-end runs require at least +//! `TEST_WALLET_CORE_FUNDING + CORE_TX_FEE_RESERVE` duffs so the +//! initial bank → test-wallet Core send clears. +//! +//! Pins the canonical asset-lock-funded registration contract: +//! 1. `setup_with_core_funded_test_wallet` lands `TEST_WALLET_CORE_FUNDING` +//! duffs on the test wallet's BIP-44 account 0 (visible to SPV). +//! 2. `IdentityWallet::register_identity_with_funding_external_signer` +//! with `IdentityFundingMethod::FundWithWallet { amount_duffs = ASSET_LOCK_AMOUNT }` +//! drives the unified asset-lock flow — internally calls +//! `AssetLockManager::create_funded_asset_lock_proof` (build → +//! broadcast → wait IS / fall back to ChainLock) and submits the +//! `IdentityCreateTransition` against the resolved proof. +//! 3. The returned `Identity` is fetchable on Platform with a balance +//! >= half the lock amount (post-fee deterministic threshold). +//! +//! Mirrors DET's `test_tc004_create_registration_asset_lock` in +//! `dash-evo-tool/tests/backend-e2e/core_tasks.rs`. + +use std::collections::BTreeMap; +use std::time::Duration; + +use dash_sdk::platform::Fetch; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::{KeyID, Purpose, SecurityLevel}; +use dpp::prelude::Identity; +use platform_wallet::wallet::identity::types::funding::IdentityFundingMethod; + +use crate::framework::prelude::*; +use crate::framework::signer::{derive_identity_key, SeedBackedIdentitySigner}; +use crate::framework::wait::wait_for_identity_balance; + +/// DIP-9 identity index used for the asset-lock registration. Slot 0 +/// is canonical for "first identity on this wallet" — same convention +/// `setup_with_n_identities` uses for its `0..n` enumeration. +const IDENTITY_INDEX: u32 = 0; + +/// Core (Layer-1) duffs the bank delivers to the test wallet's BIP-44 +/// account 0 prior to the asset-lock build. Sized at 2 DASH (testnet) +/// to comfortably cover the lock amount + fee reserve + change UTXO +/// without forcing the operator to top up between runs. The bank's +/// `send_core_to` floor is `TEST_WALLET_CORE_FUNDING + CORE_TX_FEE_RESERVE`. +const TEST_WALLET_CORE_FUNDING: u64 = 200_000_000; + +/// Amount locked into the asset-lock output (in duffs). 1 DASH on +/// testnet — well above any min-asset-lock floor and well below the +/// `TEST_WALLET_CORE_FUNDING` cap so coin selection always has change +/// to spare. +const ASSET_LOCK_AMOUNT: u64 = 100_000_000; + +/// Deadline for the on-chain identity to become balance-visible after +/// the registration transition is submitted. Matches the shape used by +/// `wallet_factory::register_identity_from_addresses` (30 s). +const IDENTITY_VISIBILITY_TIMEOUT: Duration = Duration::from_secs(60); + +#[ignore = "CR-003 — needs testnet + bank Core (Layer-1) pre-funding. \ + Framework gates cleared: SPV runtime live (Task #15), \ + BankWallet::send_core_to wired (ID-007 / CR-003), and \ + setup_with_core_funded_test_wallet helper landed. End-to-end \ + run requires at least TEST_WALLET_CORE_FUNDING + \ + CORE_TX_FEE_RESERVE duffs on the bank's primary Core receive \ + address (logged at framework init under target \ + platform_wallet::e2e::bank). Mirrors DET's \ + test_tc004_create_registration_asset_lock."] +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn cr_003_asset_lock_funded_registration() { + 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 test wallet pre-funded on its Core (Layer-1) + // BIP-44 account 0. The helper waits for the SPV-observed + // confirmed balance to reach `TEST_WALLET_CORE_FUNDING` before + // returning, so the asset-lock builder's coin selection has a + // confirmed UTXO available on entry. + let s = crate::framework::setup_with_core_funded_test_wallet(TEST_WALLET_CORE_FUNDING) + .await + .expect("setup_with_core_funded_test_wallet failed"); + + let network = s.ctx.config.network; + let seed_bytes = s.test_wallet.seed_bytes(); + let pre_lock_core = s.test_wallet.core_balance_confirmed(); + assert!( + pre_lock_core >= TEST_WALLET_CORE_FUNDING, + "PRE-pin violated: setup_with_core_funded_test_wallet returned with \ + confirmed Core balance {pre_lock_core} < TEST_WALLET_CORE_FUNDING \ + {TEST_WALLET_CORE_FUNDING} — the helper's wait_for_core_balance \ + contract has been broken or the funding amount changed without \ + updating this assertion." + ); + + // Step 2: derive the identity key set the new identity will be + // created with. Slot 0 → MASTER (mandatory signer for the + // IdentityCreate transition itself); slot 1 → HIGH (general + // signing); slot 2 → TRANSFER + CRITICAL (DPP enforces a TRANSFER + // key for credit-transfer transitions). Matches the trio + // `register_identity_from_addresses` builds for the address-funded + // path so downstream consumers (id_003 / id_005 / dpns_001) can + // exercise this identity uniformly with the address-funded ones. + let master_key = derive_identity_key( + &seed_bytes, + network, + IDENTITY_INDEX, + 0, + Purpose::AUTHENTICATION, + SecurityLevel::MASTER, + ) + .expect("derive MASTER auth key (slot 0, key 0)"); + let high_key = derive_identity_key( + &seed_bytes, + network, + IDENTITY_INDEX, + 1, + Purpose::AUTHENTICATION, + SecurityLevel::HIGH, + ) + .expect("derive HIGH auth key (slot 0, key 1)"); + let transfer_key = derive_identity_key( + &seed_bytes, + network, + IDENTITY_INDEX, + 2, + Purpose::TRANSFER, + SecurityLevel::CRITICAL, + ) + .expect("derive TRANSFER key (slot 0, key 2)"); + + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + let mut keys_map = BTreeMap::new(); + keys_map.insert(master_key.id() as u32, master_key.clone()); + keys_map.insert(high_key.id() as u32, high_key.clone()); + keys_map.insert(transfer_key.id() as u32, transfer_key.clone()); + + let identity_signer = SeedBackedIdentitySigner::new(&seed_bytes, network, IDENTITY_INDEX) + .expect("build SeedBackedIdentitySigner for identity slot 0"); + + // Step 3: drive the unified asset-lock-funded registration. The + // wallet: + // 1. Calls AssetLockManager::create_funded_asset_lock_proof — + // builds the asset-lock tx, broadcasts it, waits for the + // InstantSend lock (or falls back to ChainLock if needed), + // derives the one-time private key. + // 2. Submits the IdentityCreate transition with the resolved + // proof + per-key signatures via the supplied signer. + // 3. Returns the confirmed `Identity` with its balance populated. + let identity = s + .test_wallet + .platform_wallet() + .identity() + .register_identity_with_funding_external_signer( + IdentityFundingMethod::FundWithWallet { + amount_duffs: ASSET_LOCK_AMOUNT, + }, + IDENTITY_INDEX, + keys_map, + &identity_signer, + None, + ) + .await + .expect( + "register_identity_with_funding_external_signer (CR-003 — \ + asset-lock-funded identity registration)", + ); + + let identity_id = identity.id(); + let initial_balance = identity.balance(); + tracing::info!( + target: "platform_wallet::e2e::cases::cr_003", + %identity_id, + initial_balance, + asset_lock_amount = ASSET_LOCK_AMOUNT, + "CR-003: identity registered via asset lock" + ); + + // Step 4: assert the identity is independently fetchable on + // Platform with a balance >= half the lock amount. The half-lock + // threshold is a deterministic, fee-tolerant lower bound — testnet + // chain-time fees are well below `ASSET_LOCK_AMOUNT / 2`, so this + // round-trips even across protocol-version fee bumps without + // pinning a brittle exact number. + let observed_balance = wait_for_identity_balance( + s.test_wallet.platform_wallet().sdk(), + identity_id, + ASSET_LOCK_AMOUNT / 2, + IDENTITY_VISIBILITY_TIMEOUT, + ) + .await + .expect("identity balance reached half-lock threshold"); + assert!( + observed_balance <= ASSET_LOCK_AMOUNT, + "POST-pin violated: observed identity balance {observed_balance} > \ + ASSET_LOCK_AMOUNT {ASSET_LOCK_AMOUNT}. Registration cannot credit more \ + than the asset-lock output value (fees are subtracted, not added)." + ); + + // Step 5: round-trip the identity via the SDK to assert the + // returned shape matches the on-chain shape — same MASTER key id, + // same balance, same revision = 0 baseline. + let fetched = Identity::fetch(s.test_wallet.platform_wallet().sdk(), identity_id) + .await + .expect("Identity::fetch round-trip after registration") + .expect("registered identity must be fetchable on platform"); + assert_eq!( + fetched.id(), + identity_id, + "POST-pin violated: fetched identity id {} != registered id {}", + fetched.id(), + identity_id + ); + let fetched_master = fetched + .public_keys() + .get(&(0_u32 as KeyID)) + .expect("fetched identity missing slot-0 (MASTER) key"); + assert_eq!( + fetched_master.security_level(), + SecurityLevel::MASTER, + "POST-pin violated: slot-0 key on fetched identity is not MASTER \ + (got {:?}). The IdentityCreate transition is required to be signed \ + by a MASTER-level key at id=0 — a non-MASTER slot-0 means the \ + protocol accepted a malformed registration.", + fetched_master.security_level() + ); + + // Step 6: assert the asset-lock manager removed the tracked entry + // for the consumed lock. `funded_register_identity`'s success path + // does this via `remove_asset_lock` after the IdentityCreate + // transition lands; the legacy + // `register_identity_with_funding_external_signer` path does NOT + // remove on success today (verified at registration.rs — it only + // tracks via `create_funded_asset_lock_proof`'s internal + // changeset). We pin the looser contract: every tracked lock must + // be in `InstantSendLocked` / `ChainLocked` final state, never + // stuck at `Built` or `Broadcast`. If upstream tightens to + // remove-on-success, flip this to `assert!(tracked.is_empty())`. + let tracked = s + .test_wallet + .platform_wallet() + .asset_locks() + .list_tracked_locks() + .await; + for lock in &tracked { + use platform_wallet::wallet::asset_lock::tracked::AssetLockStatus; + assert!( + matches!( + lock.status, + AssetLockStatus::InstantSendLocked | AssetLockStatus::ChainLocked + ), + "POST-pin violated: tracked asset lock {:?} is in non-final \ + status {:?} after register_identity_with_funding_external_signer \ + completed. The unified flow must drive every consumed lock to \ + a finalised proof state.", + lock.out_point, + lock.status + ); + } + + s.teardown().await.expect("teardown"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/mod.rs b/packages/rs-platform-wallet/tests/e2e/cases/mod.rs index b09ab25510..e316c2a97e 100644 --- a/packages/rs-platform-wallet/tests/e2e/cases/mod.rs +++ b/packages/rs-platform-wallet/tests/e2e/cases/mod.rs @@ -6,6 +6,7 @@ //! TEST_SPEC.md priorities (P1, P2, ID-, DP-, DPNS-, TK-, …) follow //! in subsequent PRs. +pub mod cr_003_asset_lock_funded_registration; pub mod dpns_001_register_name; pub mod id_001_register_identity_from_addresses; pub mod id_002_top_up_identity; From 3bf42758ec082eba33b0950a5fda1715e4c2480d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 5 May 2026 14:50:50 +0200 Subject: [PATCH 12/34] =?UTF-8?q?docs(rs-platform-wallet/e2e):=20mark=20CR?= =?UTF-8?q?-003=20status=20STUB=20=E2=80=94=20implementation=20present?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flips CR-003 from `BLOCKED — needs harness refactor` to `STUB — full test body implemented, #[ignore]-tagged behind testnet env vars + bank Core funding`. Documents the framework prerequisites that landed (SPV runtime, `BankWallet::send_core_to`, `setup_with_core_funded_test_wallet`) and the exact funding floor the operator needs on the bank's Core address before a non-`--ignored` run can clear: `TEST_WALLET_CORE_FUNDING + CORE_TX_FEE_RESERVE` ≈ 2.0001 DASH testnet. Updates the wallet-feature-exercised pointer to the unified `register_identity_with_funding_external_signer` flow. Annotates the Quick Index row to match. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md b/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md index 62ceb8ec88..38a8a4a4ec 100644 --- a/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md +++ b/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md @@ -151,7 +151,7 @@ Source citations for the "Wallet API exists" column are listed inline per case | TK-014 | Group-action gateway: queue a mint, list pending, co-sign | P2 | L | | CR-001 | SPV mn-list sync readiness | P1 | M | | CR-002 | Core wallet receive address derivation | P1 | M | -| CR-003 | Asset-lock-funded identity registration (full path) | P2 | L | +| CR-003 | Asset-lock-funded identity registration (full path) (STUB — needs bank Core funding) | P2 | L | | CT-001 | Document put: deploy a fixture data contract | P1 | M | | CT-002 | Document put / replace lifecycle | P2 | M | | CT-003 | Contract update (add document type) | P2 | M | @@ -1350,8 +1350,8 @@ so that when SPV lands, the test bodies can be written without further design. #### CR-003 — Asset-lock-funded identity registration (full path) - **Priority**: P2 (post-Task #15) -- **Status**: BLOCKED — needs harness refactor: SPV runtime + Core-UTXO funded test wallet (Task #15). Bank wallet today holds platform credits, not Core coins. -- **Wallet feature exercised**: `wallet/asset_lock/build.rs:39` + `wallet/identity/network/registration.rs:240` (`register_identity_with_signer`). +- **Status**: STUB — full test body implemented at `tests/e2e/cases/cr_003_asset_lock_funded_registration.rs`, `#[ignore]`-tagged. Framework prerequisites cleared: SPV runtime live (Task #15), `BankWallet::send_core_to` wired (ID-007 / CR-003), and the new `framework::setup_with_core_funded_test_wallet(duffs)` helper lands `TEST_WALLET_CORE_FUNDING` duffs on the test wallet's BIP-44 account 0 before the asset-lock build. End-to-end runs are gated on the bank's Core (Layer-1) primary receive address holding at least `TEST_WALLET_CORE_FUNDING + CORE_TX_FEE_RESERVE` (≈ 200_010_000 duffs ≈ 2.0001 DASH testnet); under-funded surfaces as `FrameworkError::Bank` with the bank's Core address embedded so the operator-actionable "top up at <addr>" message reaches the test log unchanged. The bank Core address is logged once per process at framework init under the `platform_wallet::e2e::bank` target. +- **Wallet feature exercised**: `wallet/asset_lock/build.rs:39` (`build_asset_lock_transaction`) + `wallet/asset_lock/build.rs:285` (`create_funded_asset_lock_proof`) + `wallet/identity/network/registration.rs:59` (`register_identity_with_funding_external_signer` driving `IdentityFundingMethod::FundWithWallet`). - **DET parallel**: `dash-evo-tool/tests/backend-e2e/core_tasks.rs:132` (`test_tc004_create_registration_asset_lock`). - **Preconditions**: CR-001 + a Core-funded test wallet (operator funds via testnet faucet). - **Scenario**: build asset-lock tx; wait for instant-lock; register identity. From fcb1ac323b035e232223bc661c3d979245d7f1e2 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 5 May 2026 15:09:23 +0200 Subject: [PATCH 13/34] feat(rs-platform-wallet): add birth_height_override to wallet creation API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both `create_wallet_from_mnemonic` and `create_wallet_from_seed_bytes` now take a `birth_height_override: Option` controlling the SPV compact-filter scan window for the new wallet: - `None` keeps the prior behaviour (seed birth height to SPV's current confirmed header tip — fine for fresh wallets that only need to see funding from now on). - `Some(0)` requests a full historical scan from genesis, required when an address may have received funds before registration. - `Some(h)` pins the scan to a specific block height. The override flows through `register_wallet` into both the in-memory `ManagedWalletInfo` checkpoint and the persisted `WalletMetadataEntry` so the SPV scan window is consistent across restarts. Previously those two carried independent values (in-memory hardcoded to 0, persisted seeded from SPV tip), which was incoherent. FFI bindings and the basic_usage example pass `None` to preserve existing semantics. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rs-platform-wallet-ffi/src/manager.rs | 9 ++- .../examples/basic_usage.rs | 1 + .../src/manager/wallet_lifecycle.rs | 62 ++++++++++++++----- 3 files changed, 56 insertions(+), 16 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 37661da350..b875769a84 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -101,7 +101,7 @@ pub unsafe extern "C" fn platform_wallet_manager_create_wallet_from_seed( }; let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |manager| { - runtime().block_on(manager.create_wallet_from_seed_bytes(network, seed, accounts)) + runtime().block_on(manager.create_wallet_from_seed_bytes(network, seed, accounts, None)) }); let result = unwrap_option_or_return!(option); let wallet = unwrap_result_or_return!(result); @@ -139,7 +139,12 @@ pub unsafe extern "C" fn platform_wallet_manager_create_wallet_from_mnemonic( }; let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |manager| { - runtime().block_on(manager.create_wallet_from_mnemonic(mnemonic_str, network, accounts)) + runtime().block_on(manager.create_wallet_from_mnemonic( + mnemonic_str, + network, + accounts, + None, + )) }); let result = unwrap_option_or_return!(option); let wallet = unwrap_result_or_return!(result); diff --git a/packages/rs-platform-wallet/examples/basic_usage.rs b/packages/rs-platform-wallet/examples/basic_usage.rs index 20e6db8cd8..26913d5228 100644 --- a/packages/rs-platform-wallet/examples/basic_usage.rs +++ b/packages/rs-platform-wallet/examples/basic_usage.rs @@ -60,6 +60,7 @@ async fn main() -> Result<(), Box> { Network::Testnet, seed_bytes, WalletAccountCreationOptions::Default, + None, ) .await?; diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index 1042feb440..ef44ebb28f 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -56,11 +56,23 @@ impl PlatformWalletManager

{ /// [`parse_mnemonic_any_language`]). For passphrase-only flows or /// out-of-band seed material, derive the seed externally and use /// [`Self::create_wallet_from_seed_bytes`]. + /// + /// `birth_height_override` controls SPV's compact-filter scan + /// window for the new wallet. `None` (the default for fresh + /// wallets) seeds the birth height to SPV's current confirmed + /// header tip, so the scan window is `[H_now, ∞)` — anything + /// funded before init is invisible. `Some(0)` requests a full + /// historical scan from genesis (use sparingly — expensive on + /// long-lived chains, but required when an address may have + /// received funds before the wallet was first registered). + /// `Some(h)` pins the scan start to a specific block height, + /// useful when a known funding block is on record. pub async fn create_wallet_from_mnemonic( &self, mnemonic_phrase: &str, network: Network, accounts: WalletAccountCreationOptions, + birth_height_override: Option, ) -> Result, PlatformWalletError> { let mnemonic = parse_mnemonic_any_language(mnemonic_phrase) .map_err(|e| PlatformWalletError::WalletCreation(format!("Invalid mnemonic: {}", e)))?; @@ -70,16 +82,24 @@ impl PlatformWalletManager

{ e )) })?; - self.register_wallet(wallet).await + self.register_wallet(wallet, birth_height_override).await } /// Create a PlatformWallet from raw seed bytes, initialize persisted /// state, register it with the manager and return an `Arc` handle. + /// + /// See [`Self::create_wallet_from_mnemonic`] for the + /// `birth_height_override` semantics. `None` keeps the + /// pre-existing behaviour (scan from current SPV tip forward); + /// `Some(h)` is for callers that need to see funding deposited + /// before the wallet was registered (e.g. a long-lived bank + /// address pre-funded with testnet duffs). pub async fn create_wallet_from_seed_bytes( &self, network: Network, seed_bytes: [u8; 64], accounts: WalletAccountCreationOptions, + birth_height_override: Option, ) -> Result, PlatformWalletError> { let wallet = Wallet::from_seed_bytes(seed_bytes, network, accounts).map_err(|e| { PlatformWalletError::WalletCreation(format!( @@ -87,18 +107,39 @@ impl PlatformWalletManager

{ e )) })?; - self.register_wallet(wallet).await + self.register_wallet(wallet, birth_height_override).await } /// Register a pre-built `Wallet` with the manager: insert into the /// `WalletManager`, build a `PlatformWallet` handle, load persisted /// state, and return an `Arc` to the managed wallet. + /// + /// `birth_height_override` flows through to both the in-memory + /// `ManagedWalletInfo` sync checkpoint and the persisted + /// `WalletMetadataEntry` so the SPV scan window is consistent + /// across restarts. See [`Self::create_wallet_from_mnemonic`] for + /// the contract. #[allow(clippy::type_complexity)] async fn register_wallet( &self, wallet: Wallet, + birth_height_override: Option, ) -> Result, PlatformWalletError> { - let wallet_info = ManagedWalletInfo::from_wallet(&wallet, 0); + // Birth height resolution: explicit override wins; otherwise + // fall back to SPV's confirmed header tip (default for fresh + // wallets — they only need to see funding from now on); 0 if + // SPV isn't running yet. + let birth_height: u32 = match birth_height_override { + Some(h) => h, + None => self + .spv_manager + .sync_progress() + .await + .and_then(|p| p.headers().ok().map(|h| h.tip_height())) + .unwrap_or(0), + }; + + let wallet_info = ManagedWalletInfo::from_wallet(&wallet, birth_height); let balance = Arc::new(WalletBalance::new()); @@ -192,17 +233,10 @@ impl PlatformWalletManager

{ // the persister is a best-effort channel, not a source of // truth in steady state. - // Birth height = SPV's confirmed header tip if SPV is running, - // otherwise 0 (caller can bump it later when SPV catches up). - // 0 means "scan from genesis", which is safe-correct for - // fresh wallets. - let birth_height: u32 = self - .spv_manager - .sync_progress() - .await - .and_then(|p| p.headers().ok().map(|h| h.tip_height())) - .unwrap_or(0); - + // `birth_height` was resolved at the top of `register_wallet` + // and seeded into `ManagedWalletInfo`; reuse it here so the + // persisted `WalletMetadataEntry` agrees with the in-memory + // sync checkpoint. let mut registration_changeset = PlatformWalletChangeSet { wallet_metadata: Some(WalletMetadataEntry { network: self.sdk.network, From cbc4302c4b518177f37f6672939f01faba02c3f2 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 5 May 2026 15:09:37 +0200 Subject: [PATCH 14/34] fix(rs-platform-wallet/e2e): use birth_height=0 for bank wallet so historical L1 funding is visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bank is a long-lived testnet address that may receive Layer-1 funding before any test run starts. Pinning `birth_height` to the current SPV tip (the previous default) made the compact-filter scan window `[H_now, ∞)`, hiding any UTXO confirmed before init — exactly the QA-001 / QA-002 / QA-003 finding documented in `/tmp/bank-core-balance-diagnosis.md`. The user's confirmed 4 DASH at `yXyzNWRRASxYzWwskmqNmb5xFjGc94bn5F` was being reported as zero for this reason. Pass `Some(0)` to `create_wallet_from_mnemonic` so SPV scans from genesis. Other test callers (`TestWallet::create`, post-sweep re-derivations, cleanup sweeps, the `spv_sync` integration test) still pass `None` — fresh test wallets don't need historical scan. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tests/e2e/cases/pa_004_sweep_back.rs | 7 ++++++- .../e2e/cases/pa_004b_sweep_dust_boundary.rs | 7 ++++++- .../tests/e2e/cases/pa_009_min_input_amount.rs | 7 ++++++- .../tests/e2e/framework/bank.rs | 18 ++++++++++++++++++ .../tests/e2e/framework/cleanup.rs | 7 ++++++- .../tests/e2e/framework/wallet_factory.rs | 1 + packages/rs-platform-wallet/tests/spv_sync.rs | 7 ++++++- 7 files changed, 49 insertions(+), 5 deletions(-) diff --git a/packages/rs-platform-wallet/tests/e2e/cases/pa_004_sweep_back.rs b/packages/rs-platform-wallet/tests/e2e/cases/pa_004_sweep_back.rs index 9fb2968bc7..ab4333b315 100644 --- a/packages/rs-platform-wallet/tests/e2e/cases/pa_004_sweep_back.rs +++ b/packages/rs-platform-wallet/tests/e2e/cases/pa_004_sweep_back.rs @@ -135,7 +135,12 @@ async fn pa_004_sweep_back_drains_to_bank() { // assertion can't pass on stale memory — only on-chain truth. let post_sweep = ctx .manager() - .create_wallet_from_seed_bytes(network, seed_bytes, WalletAccountCreationOptions::Default) + .create_wallet_from_seed_bytes( + network, + seed_bytes, + WalletAccountCreationOptions::Default, + None, + ) .await .expect("re-derive post-sweep view of test wallet"); post_sweep.platform().initialize().await; diff --git a/packages/rs-platform-wallet/tests/e2e/cases/pa_004b_sweep_dust_boundary.rs b/packages/rs-platform-wallet/tests/e2e/cases/pa_004b_sweep_dust_boundary.rs index f03800884c..7e44b613e4 100644 --- a/packages/rs-platform-wallet/tests/e2e/cases/pa_004b_sweep_dust_boundary.rs +++ b/packages/rs-platform-wallet/tests/e2e/cases/pa_004b_sweep_dust_boundary.rs @@ -224,7 +224,12 @@ async fn pa_004b_sweep_below_dust_gate_no_broadcast() { // state of the gone TestWallet. Read straight off chain. let post_sweep = ctx .manager() - .create_wallet_from_seed_bytes(network, seed_bytes, WalletAccountCreationOptions::Default) + .create_wallet_from_seed_bytes( + network, + seed_bytes, + WalletAccountCreationOptions::Default, + None, + ) .await .expect("re-derive post-sweep view of test wallet"); post_sweep.platform().initialize().await; diff --git a/packages/rs-platform-wallet/tests/e2e/cases/pa_009_min_input_amount.rs b/packages/rs-platform-wallet/tests/e2e/cases/pa_009_min_input_amount.rs index b7e85c7f95..9ef82d8496 100644 --- a/packages/rs-platform-wallet/tests/e2e/cases/pa_009_min_input_amount.rs +++ b/packages/rs-platform-wallet/tests/e2e/cases/pa_009_min_input_amount.rs @@ -197,7 +197,12 @@ async fn pa_009_cleanup_gate_tracks_platform_version_min_input_amount() { let post_sweep = ctx .manager() - .create_wallet_from_seed_bytes(network, seed_bytes, WalletAccountCreationOptions::Default) + .create_wallet_from_seed_bytes( + network, + seed_bytes, + WalletAccountCreationOptions::Default, + None, + ) .await .expect("re-derive post-sweep view of test wallet"); post_sweep.platform().initialize().await; diff --git a/packages/rs-platform-wallet/tests/e2e/framework/bank.rs b/packages/rs-platform-wallet/tests/e2e/framework/bank.rs index bfa8036a81..de397e39a1 100644 --- a/packages/rs-platform-wallet/tests/e2e/framework/bank.rs +++ b/packages/rs-platform-wallet/tests/e2e/framework/bank.rs @@ -166,11 +166,19 @@ impl BankWallet { let seed_bytes = validated.to_seed(""); let network = config.network; + // `Some(0)` requests a full historical compact-filter scan + // from genesis. The bank is a long-lived testnet address + // that may receive Layer-1 funding before any test run + // starts; without this, SPV's default "scan from current + // tip" window would miss those UTXOs and report + // `core_balance=0` even when funded (QA-001 / QA-002 / + // QA-003 in `/tmp/bank-core-balance-diagnosis.md`). let wallet = manager .create_wallet_from_mnemonic( &config.bank_mnemonic, network, key_wallet::wallet::initialization::WalletAccountCreationOptions::Default, + Some(0), ) .await .map_err(wallet_err)?; @@ -373,6 +381,16 @@ impl BankWallet { self.wallet.balance().confirmed() } + /// Bank wallet's SPV birth height — the earliest block SPV's + /// compact-filter scan will inspect for this wallet. Surfaced in + /// the harness init log so operators can correlate `core_balance=0` + /// with the scan window: if the funding tx confirmed below + /// `birth_height`, SPV won't see it. + pub async fn birth_height(&self) -> u32 { + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + self.wallet.state().await.birth_height() + } + /// First BIP-44 (Core) receive address. Stable across process /// runs while the address remains unused — once a UTXO lands on /// it the pool advances and a subsequent call returns the next diff --git a/packages/rs-platform-wallet/tests/e2e/framework/cleanup.rs b/packages/rs-platform-wallet/tests/e2e/framework/cleanup.rs index f83e6c9291..8a93726a5a 100644 --- a/packages/rs-platform-wallet/tests/e2e/framework/cleanup.rs +++ b/packages/rs-platform-wallet/tests/e2e/framework/cleanup.rs @@ -106,7 +106,12 @@ async fn sweep_one( ) -> FrameworkResult<()> { let seed_bytes: [u8; 64] = parse_seed_hex(&entry.seed_hex)?; let wallet = manager - .create_wallet_from_seed_bytes(network, seed_bytes, WalletAccountCreationOptions::Default) + .create_wallet_from_seed_bytes( + network, + seed_bytes, + WalletAccountCreationOptions::Default, + None, + ) .await .map_err(wallet_err)?; if wallet.wallet_id() != *hash { diff --git a/packages/rs-platform-wallet/tests/e2e/framework/wallet_factory.rs b/packages/rs-platform-wallet/tests/e2e/framework/wallet_factory.rs index 9376d10e66..48d25d032e 100644 --- a/packages/rs-platform-wallet/tests/e2e/framework/wallet_factory.rs +++ b/packages/rs-platform-wallet/tests/e2e/framework/wallet_factory.rs @@ -97,6 +97,7 @@ impl TestWallet { network, seed_bytes, WalletAccountCreationOptions::Default, + None, ) .await .map_err(wallet_err)?; diff --git a/packages/rs-platform-wallet/tests/spv_sync.rs b/packages/rs-platform-wallet/tests/spv_sync.rs index 86011d5ea8..fb621bafcd 100644 --- a/packages/rs-platform-wallet/tests/spv_sync.rs +++ b/packages/rs-platform-wallet/tests/spv_sync.rs @@ -182,7 +182,12 @@ async fn test_spv_sync_and_balance() { let seed_bytes = mnemonic.to_seed(""); let platform_wallet = manager - .create_wallet_from_seed_bytes(network, seed_bytes, WalletAccountCreationOptions::Default) + .create_wallet_from_seed_bytes( + network, + seed_bytes, + WalletAccountCreationOptions::Default, + None, + ) .await .expect("Failed to create platform wallet"); From aead0cc6a7f6b095e6a17d433e4fda95dedb6cda Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 5 May 2026 15:09:47 +0200 Subject: [PATCH 15/34] feat(rs-platform-wallet/e2e): surface bank birth_height in init log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA-003 LOW: the bank pre-flight log already showed `bank_core_addr` and `bank_core_balance`, but not `birth_height` — leaving operators unable to tell "wallet starts above your funding tx" (filter scan window past the funding block) from "your tx hasn't confirmed yet" (legitimate zero balance) when seeing `bank_core_balance=0`. Add `birth_height` to both the info and warn variants of the BANK CORE ADDRESS log line, plus a separate WARN when the balance is zero and birth_height > 0 explaining that any funding tx confirmed below the birth_height is invisible to SPV until re-broadcast. The bank itself now passes `Some(0)`, so the warn is defence-in-depth for the case where someone changes that behaviour without updating the operator-facing diagnostic. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tests/e2e/framework/harness.rs | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/rs-platform-wallet/tests/e2e/framework/harness.rs b/packages/rs-platform-wallet/tests/e2e/framework/harness.rs index fe2cd3967c..60ea5d1bfc 100644 --- a/packages/rs-platform-wallet/tests/e2e/framework/harness.rs +++ b/packages/rs-platform-wallet/tests/e2e/framework/harness.rs @@ -167,20 +167,43 @@ impl E2eContext { // fetching the address are demoted to a warning so framework // init isn't gated on Core paths that most tests bypass // entirely. + // QA-003: surface the bank's `birth_height` next to the + // address + balance so operators can tell "wallet starts + // above your funding tx" from "your tx hasn't confirmed yet". + // When `core_balance == 0` and `birth_height > 0`, SPV's + // compact-filter scan window starts past genesis, so any + // funding tx confirmed at a lower block is invisible until + // re-broadcast at a height ≥ `birth_height`. The bank + // currently passes `Some(0)` to bypass this entirely (see + // `BankWallet::load`); the warn is defence-in-depth in case + // that ever regresses. + let bank_birth_height = bank.birth_height().await; + let bank_core_balance = bank.core_balance_confirmed(); match bank.primary_core_receive_address().await { Ok(addr) => tracing::info!( target: "platform_wallet::e2e::bank", bank_core_addr = %addr, - bank_core_balance = bank.core_balance_confirmed(), + bank_core_balance, + birth_height = bank_birth_height, "═══ BANK CORE ADDRESS (fund here for CR-* / ID-007 tests) ═══" ), Err(err) => tracing::warn!( target: "platform_wallet::e2e::bank", error = %err, - bank_core_balance = bank.core_balance_confirmed(), + bank_core_balance, + birth_height = bank_birth_height, "Bank Core address derivation failed; pre-flight log incomplete" ), } + if bank_core_balance == 0 && bank_birth_height > 0 { + tracing::warn!( + target: "platform_wallet::e2e::bank", + birth_height = bank_birth_height, + "Bank Core balance is zero with birth_height > 0 — SPV's filter \ + scan starts at this block; any funding tx confirmed below it \ + is invisible until re-broadcast at a height ≥ birth_height" + ); + } // Resolve / register the bank identity BEFORE the orphan // sweep so [`cleanup::sweep_orphans`] has a valid sweep From d573cf8874b5071daa6296c597e84fe7f0e5f9d4 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Tue, 5 May 2026 20:18:07 +0700 Subject: [PATCH 16/34] ci: drop '!path' negation patterns from JS package filter (#3592) Co-authored-by: Claude Opus 4.7 (1M context) --- .../js-packages-no-workflows.yml | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/.github/package-filters/js-packages-no-workflows.yml b/.github/package-filters/js-packages-no-workflows.yml index 91f3a58a79..1ecb901243 100644 --- a/.github/package-filters/js-packages-no-workflows.yml +++ b/.github/package-filters/js-packages-no-workflows.yml @@ -34,12 +34,18 @@ - packages/rs-platform-version/** - packages/rs-platform-versioning/** - packages/rs-dpp/** - # Exclude Rust test files — they don't affect WASM builds - - '!packages/rs-dpp/**/tests.rs' - - '!packages/rs-dpp/**/tests/**' - - '!packages/rs-dpp/**/test_helpers/**' - - '!packages/rs-dpp/**/test_utils.rs' - - '!packages/rs-dpp/**/test_utils/**' + # NOTE: do not add `!packages/rs-dpp/**/tests.rs` style negation + # patterns here. The dispatcher in `tests.yml` runs these filters + # via `dorny/paths-filter@v3` with the default + # `predicate-quantifier: some`, under which each pattern (including + # `!`-prefixed ones) is OR'd independently. A `!` pattern then + # "matches" every file that doesn't match the negated path — i.e. + # virtually every file in the repo — which trips this filter on + # Swift-only / Rust-only changes and cascades through every other + # filter that aliases `*wasm-dpp` (dapi, dapi-client, wallet-lib, + # dash, dashmate, platform-test-suite, …). Cheaper to over-trigger + # the WASM tests on rs-dpp test-only edits than to mis-trigger + # half the JS test matrix on every PR. '@dashevo/wasm-dpp2': &wasm-dpp2 - packages/wasm-dpp2/** @@ -96,13 +102,10 @@ dashmate: - packages/rs-platform-version/** - packages/rs-dash-platform-macros/** - packages/dapi-grpc/** - # Exclude Rust test files — they don't affect WASM builds - - '!packages/rs-drive-proof-verifier/**/tests.rs' - - '!packages/rs-drive-proof-verifier/**/tests/**' - - '!packages/rs-sdk/**/tests.rs' - - '!packages/rs-sdk/**/tests/**' - - '!packages/rs-dapi-client/**/tests.rs' - - '!packages/rs-dapi-client/**/tests/**' + # NOTE: do not add `!path` negation patterns here — see the long + # explanation on `@dashevo/wasm-dpp` above. Same `dorny/paths-filter@v3` + # + `predicate-quantifier: some` interaction trips this filter on + # unrelated changes and cascades into every consumer (`*wasm-sdk`). '@dashevo/evo-sdk': &evo-sdk - packages/js-evo-sdk/** From 26cec3392b06f31562ce347faf7c82cf2f30406c Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Tue, 5 May 2026 20:19:31 +0700 Subject: [PATCH 17/34] fix(swift-example-app): hold one PlatformWalletManager per network (#3591) Co-authored-by: Claude Opus 4.7 (1M context) --- .../PlatformWalletManager.swift | 22 ++- .../PlatformWalletPersistenceHandler.swift | 29 +++- .../SwiftExampleApp/SwiftExampleAppApp.swift | 150 +++++++++++------- .../SwiftExampleApp/WalletManagerStore.swift | 115 ++++++++++++++ 4 files changed, 252 insertions(+), 64 deletions(-) create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 6d134fe2fb..59c7067e5e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -97,17 +97,33 @@ public class PlatformWalletManager: ObservableObject { "dash_sdk_get_inner_sdk_ptr returned NULL for the supplied SDK" ) } - try configure(sdkPointer: UnsafeRawPointer(innerSdkPtr), modelContainer: modelContainer) + // The Rust manager is network-locked at construction + // (`WalletManager::new(sdk.network)`); thread that same + // network through to the persistence handler so its + // `loadWalletList` only restores wallets bound to this + // network, matching the per-network manager design. + try configure( + sdkPointer: UnsafeRawPointer(innerSdkPtr), + modelContainer: modelContainer, + network: sdk.network + ) } /// Configure with a raw Sdk pointer (advanced usage). - public func configure(sdkPointer: UnsafeRawPointer, modelContainer: ModelContainer? = nil) throws { + public func configure( + sdkPointer: UnsafeRawPointer, + modelContainer: ModelContainer? = nil, + network: Network? = nil + ) throws { var handle: Handle = NULL_HANDLE let handler: PlatformWalletPersistenceHandler? var persistence: PersistenceCallbacks if let container = modelContainer { - let h = PlatformWalletPersistenceHandler(modelContainer: container) + let h = PlatformWalletPersistenceHandler( + modelContainer: container, + network: network + ) persistence = h.makeCallbacks() handler = h } else { diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index c7c3f68303..345ff65277 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -10,6 +10,15 @@ import DashSDKFFI public class PlatformWalletPersistenceHandler { let modelContainer: ModelContainer + /// Network this handler's owning `PlatformWalletManager` is bound + /// to. When set, `loadWalletList` filters out persisted wallets + /// from other networks so a per-network manager only restores its + /// own wallets. `nil` keeps the legacy "load every wallet" + /// behavior for callers that don't yet thread network through — + /// once the example app's `WalletManagerStore` is the only + /// caller, the `nil` path can be retired. + let network: Network? + /// Background context for writing from callback threads. /// /// `ModelContext` is not thread-safe — touching it from the @@ -37,8 +46,9 @@ public class PlatformWalletPersistenceHandler { /// atomically. private var inChangeset = false - public init(modelContainer: ModelContainer) { + public init(modelContainer: ModelContainer, network: Network? = nil) { self.modelContainer = modelContainer + self.network = network self.backgroundContext = ModelContext(modelContainer) self.backgroundContext.autosaveEnabled = true } @@ -2120,7 +2130,22 @@ public class PlatformWalletPersistenceHandler { /// Returns `(nil, 0)` if nothing is restorable. func loadWalletList() -> (entries: UnsafePointer?, count: Int, errored: Bool) { onQueue { - let walletDescriptor = FetchDescriptor() + // Scope the fetch to the handler's bound network so a + // per-network manager only sees its own wallets. If + // `network` is `nil` (legacy callers that haven't threaded + // network through yet) we fall back to the cross-network + // fetch — those callers were already fragile against + // cross-network data and the new path keeps them on the + // pre-refactor behavior until they migrate. + let walletDescriptor: FetchDescriptor + if let network = self.network { + let raw = network.rawValue + walletDescriptor = FetchDescriptor( + predicate: #Predicate { $0.networkRaw == raw } + ) + } else { + walletDescriptor = FetchDescriptor() + } let wallets: [PersistentWallet] do { wallets = try backgroundContext.fetch(walletDescriptor) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift index bce2086578..69caa96bef 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift @@ -27,8 +27,13 @@ struct SwiftExampleAppApp: App { // Platform identity / document / contract state. @StateObject private var platformState = AppState() - // The one wallet manager — drives SPV, BLAST sync, wallet creation, etc. - @StateObject private var walletManager = PlatformWalletManager() + // Per-network wallet managers. The Rust `PlatformWalletManager` + // is network-locked at `configure(...)` time, so swapping + // networks at runtime needs a different manager instance — + // not a reconfigured one. The store lazy-creates a manager per + // network and republishes the active one so the SwiftUI + // environment rebinds to the right instance on switch. + @StateObject private var walletManagerStore: WalletManagerStore // Remaining services. @StateObject private var shieldedService = ShieldedService() @@ -36,6 +41,14 @@ struct SwiftExampleAppApp: App { @StateObject private var transitionState = TransitionState() @StateObject private var appUIState = AppUIState() + /// Current manager exposed to views via the env object pipeline. + /// Reads from the published `activeManager` on every body + /// invocation so the env object rebinds whenever + /// `walletManagerStore.activate(...)` swaps the active manager. + private var walletManager: PlatformWalletManager { + walletManagerStore.activeManager + } + @State private var isInitialized = false @State private var bootstrapError: Error? @State private var bootstrapTask: Task? @@ -57,17 +70,35 @@ struct SwiftExampleAppApp: App { // `cleanupLegacyItems` itself. WalletStorage.cleanupLegacyItems() + let container: ModelContainer do { - self.modelContainer = try DashModelContainer.create() + container = try DashModelContainer.create() } catch { fatalError("Failed to create ModelContainer: \(error)") } + self.modelContainer = container + // Build the store eagerly so the autoclosure for + // `@StateObject` can capture the local `container` + // directly — referencing `self.modelContainer` here would + // make the autoclosure capture `self` mutating, which the + // compiler rejects. Same `container` is handed to every + // per-network manager the store lazy-creates. + _walletManagerStore = StateObject( + wrappedValue: WalletManagerStore(modelContainer: container) + ) } var body: some Scene { WindowGroup { ContentView(isInitialized: isInitialized, bootstrapError: bootstrapError, onRetry: retryBootstrap) .environmentObject(platformState) + // Re-injected on every body invocation. When the + // store swaps `activeManager` (network switch), the + // computed `walletManager` returns the new instance + // and SwiftUI rebinds the env object — the 40+ + // `@EnvironmentObject var walletManager: + // PlatformWalletManager` consumers see the right + // network's manager without any view changes. .environmentObject(walletManager) .environmentObject(shieldedService) .environmentObject(platformBalanceSyncService) @@ -92,30 +123,60 @@ struct SwiftExampleAppApp: App { })) { _, _ in rebindWalletScopedServices() } - // Rebind on network switch as well — without this, - // the balance-sync service stays pinned to whatever - // wallet was picked at bootstrap, which leaks the - // previous network's Platform Balance / Active - // Addresses / Last Sync into the Sync Status tab - // for the new network. - .onChange(of: platformState.currentNetwork) { _, _ in + // Network switch: activate the per-network manager + // first (the store lazy-creates one configured with + // a fresh SDK if this is the first time we see this + // network), then rebind the wallet-scoped services + // against it. Order matters — `rebindWalletScopedServices` + // reads `walletManager.firstWallet`, which has to + // resolve to the new network's manager before it + // runs. + .onChange(of: platformState.currentNetwork) { _, newNetwork in + activateManager(for: newNetwork) rebindWalletScopedServices() } } } + /// Lazy-create + cache a `PlatformWalletManager` for `network`, + /// configured against `platformState.sdk`. No-ops on the + /// already-active network. Called from bootstrap and from + /// `currentNetwork.onChange`. + @MainActor + private func activateManager(for network: Network) { + guard let sdk = platformState.sdk else { + SDKLogger.error( + "Cannot activate wallet manager for \(network.displayName): " + + "no SDK available (still bootstrapping?)" + ) + return + } + do { + try walletManagerStore.activate(network: network, sdk: sdk) + } catch { + SDKLogger.error( + "Failed to activate wallet manager for " + + "\(network.displayName): \(error.localizedDescription)" + ) + } + } + /// Drive manager-wide BLAST sync state from the set of loaded - /// wallets. With no wallets present *on the active network*, - /// sync is stopped and the per-wallet `PlatformBalanceSyncService` + /// wallets. With no wallets present on the active manager, sync + /// is stopped and the per-wallet `PlatformBalanceSyncService` /// UI surface is reset — so the Sync Status tab shows zeros for /// a network the user hasn't created a wallet on yet, instead /// of leaking values from a wallet on a different network. /// Otherwise, we bind the balance service to a deterministic - /// wallet on that network. (Detail views reconfigure the + /// wallet on the active manager. (Detail views reconfigure the /// service per-wallet themselves.) + /// + /// The active manager is per-network now, so its `firstWallet` + /// is already correctly scoped — no need for a separate + /// network-filtering pass at this layer. @MainActor private func rebindWalletScopedServices() { - let wallet = firstWalletOnActiveNetwork() + let wallet = walletManager.firstWallet guard let wallet else { do { try walletManager.stopPlatformAddressSync() @@ -149,32 +210,6 @@ struct SwiftExampleAppApp: App { } } - /// Lowest-walletId managed wallet that's tagged to - /// `platformState.currentNetwork`. The Rust manager doesn't - /// track networks per wallet, so the source of truth is the - /// SwiftData `PersistentWallet.networkRaw` column — which the - /// persister fills in alongside the wallet creation that - /// populated `walletManager.wallets`. Returns `nil` when the - /// active network has no managed wallet (yet). - @MainActor - private func firstWalletOnActiveNetwork() -> ManagedPlatformWallet? { - let raw = platformState.currentNetwork.rawValue - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.networkRaw == raw } - ) - let context = modelContainer.mainContext - let rows = (try? context.fetch(descriptor)) ?? [] - // Sort by walletId so the choice is deterministic across - // launches — same shape as `PlatformWalletManager.firstWallet`. - let sortedIds = rows - .map(\.walletId) - .sorted { $0.lexicographicallyPrecedes($1) } - for id in sortedIds { - if let managed = walletManager.wallets[id] { return managed } - } - return nil - } - @MainActor private func bootstrap() async { do { @@ -186,25 +221,22 @@ struct SwiftExampleAppApp: App { try? await Task.sleep(for: .milliseconds(500)) if let sdk = platformState.sdk { - // Configure the wallet manager. - try walletManager.configure(sdk: sdk, modelContainer: modelContainer) - - // Restore wallets from the persister (SwiftData). If - // no wallets have been persisted yet this is a no-op. - // Restored wallets come back watch-only; signing is - // deferred until the user unlocks via biometric + - // Keychain-stored mnemonic (future work). - do { - let restored = try walletManager.loadFromPersistor() - if !restored.isEmpty { - SDKLogger.log( - "🔓 Restored \(restored.count) wallet(s) from persister", - minimumLevel: .medium - ) - } - } catch { - SDKLogger.error( - "Failed to restore wallets from persister: \(error.localizedDescription)" + // Activate the per-network manager for the launch + // network. The store creates + configures the + // manager and runs `loadFromPersistor` against it + // (filtered to the launch network's wallets via + // the network-aware persistence handler), so no + // separate restore pass is needed here. + try walletManagerStore.activate( + network: platformState.currentNetwork, + sdk: sdk + ) + let restoredCount = walletManager.wallets.count + if restoredCount > 0 { + SDKLogger.log( + "🔓 Restored \(restoredCount) wallet(s) from persister " + + "for \(platformState.currentNetwork.displayName)", + minimumLevel: .medium ) } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift new file mode 100644 index 0000000000..28d8d32626 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift @@ -0,0 +1,115 @@ +import Foundation +import SwiftData +import SwiftUI +import SwiftDashSDK + +/// Coordinator that holds one `PlatformWalletManager` per network. +/// +/// The Rust `PlatformWalletManager` is intentionally network-locked +/// — its inner `WalletManager` is constructed with `sdk.network` at +/// `configure(...)` time and the SDK reference (plus the SPV +/// runtime, BLAST sync coordinator, and persistence handler hung off +/// it) all stay bound to that single network for the manager's +/// lifetime. Switching networks at runtime therefore needs a +/// **different manager**, not a reconfigured one. +/// +/// `WalletManagerStore` materializes that design on the iOS side: +/// +/// * Lazy-creates a `PlatformWalletManager` the first time a +/// network is activated, keying on `Network` so subsequent +/// activations of the same network return the warm manager. +/// * Each manager's persistence handler is constructed with the +/// same network (via `configure(sdk:modelContainer:)`), which +/// scopes `loadWalletList` to that network's `PersistentWallet` +/// rows — multiple managers writing into the shared SwiftData +/// store don't trip over each other on hydration. +/// * Publishes `activeManager` so the SwiftUI scene can re-inject +/// it via `.environmentObject(...)` on every switch. Existing +/// view consumers (`@EnvironmentObject var walletManager: +/// PlatformWalletManager`) keep working unchanged — they just +/// see the manager for the active network on each render after +/// a switch. +/// +/// Inactive managers stay alive in the background. SPV / BLAST sync +/// continue running for them; the user can flip Testnet ↔ Local ↔ +/// ... and resume per-network state without losing in-flight sync +/// progress. The cost is N×manager memory for N networks the user +/// has touched this session — tractable for the 4-network upper +/// bound the app supports today, and the right trade-off for a +/// dev-focused example app where flipping is common. +@MainActor +final class WalletManagerStore: ObservableObject { + /// Currently-active manager. Reassigned when the user switches + /// networks; SwiftUI re-injects this into the env object so + /// every `@EnvironmentObject var walletManager: + /// PlatformWalletManager` consumer rebinds to the right + /// instance on each render. + /// + /// Starts as a placeholder, unconfigured `PlatformWalletManager` + /// — the bootstrap path replaces it via `activate(...)` once + /// the SDK for the initial network is ready, and views are + /// gated behind `isInitialized` until that happens (same + /// gating the pre-refactor single-manager flow used). + @Published private(set) var activeManager: PlatformWalletManager + + /// Per-network managers. Lazily populated on first activation + /// of each network; lookup is O(1). + private var managers: [Network: PlatformWalletManager] = [:] + + /// SwiftData container shared across every manager. Each + /// manager's persistence handler narrows its `loadWalletList` + /// fetch to its own network so the shared store doesn't cause + /// cross-network bleed at restore time. + private let modelContainer: ModelContainer + + init(modelContainer: ModelContainer) { + self.modelContainer = modelContainer + // Placeholder. Held until the first `activate(...)` call + // replaces it with a configured manager. Views gate against + // bootstrap via `isInitialized` so they never see this + // intermediate value. + self.activeManager = PlatformWalletManager() + } + + /// Activate the manager for `network`, lazily creating + caching + /// one configured against `sdk` if it's not already warm. + /// + /// Idempotent: re-activating the current network is a no-op. + /// Failures during a fresh manager's `configure` / + /// `loadFromPersistor` propagate to the caller — the cache + /// stays untouched in that case so a later retry can succeed. + func activate(network: Network, sdk: SDK) throws { + if let existing = managers[network] { + if existing !== activeManager { + activeManager = existing + } + return + } + let manager = PlatformWalletManager() + try manager.configure(sdk: sdk, modelContainer: modelContainer) + // Best-effort: a fresh manager comes up with no wallets in + // memory; restore from the network-scoped persistence + // handler so reopening the app on this network resurfaces + // the wallets the user already created here. Failures here + // are non-fatal — the user can still create / import + // wallets, the in-memory set just starts empty. + do { + _ = try manager.loadFromPersistor() + } catch { + SDKLogger.error( + "WalletManagerStore: load-from-persistor failed for " + + "\(network.displayName): \(error.localizedDescription)" + ) + } + managers[network] = manager + activeManager = manager + } + + /// Manager for `network` if one has been activated this session; + /// otherwise `nil`. Used for diagnostics surfaces (e.g. Wallet + /// Memory Explorer) that want to inspect a specific network's + /// state without forcing it active. + func manager(for network: Network) -> PlatformWalletManager? { + managers[network] + } +} From fc8bce18cca9dc0f39672edb68aaef8dc59bfb5b Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Tue, 5 May 2026 20:19:54 +0700 Subject: [PATCH 18/34] feat(swift-example-app): debounced live validation of faucet RPC password (#3590) Co-authored-by: Claude Opus 4.7 (1M context) --- .../SwiftExampleApp/Views/OptionsView.swift | 176 +++++++++++++++++- 1 file changed, 169 insertions(+), 7 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift index 6227291994..4d82cb3f85 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift @@ -13,6 +13,58 @@ struct OptionsView: View { @State private var sdkStatus: SDKStatus? @State private var isLoadingStatus = false + /// Live-edit copy of the faucet RPC password. Round-trips through + /// `UserDefaults` on every `.onChange` so other surfaces + /// (`ReceiveAddressView.requestFromFaucet`) keep reading the + /// authoritative value, while a local `@State` lets the + /// debounced `.task(id:)` validator see edits immediately + /// without a fetch round-trip per keystroke. + @State private var faucetPassword: String = + UserDefaults.standard.string(forKey: "faucetRPCPassword") ?? "" + @State private var faucetValidation: FaucetValidationStatus = .idle + + /// Driven by the 0.5s-debounced `.task(id: faucetPassword)`. + /// Runs a single cheap `getblockcount` JSON-RPC against the + /// dashmate-managed Core (`127.0.0.1:`) and + /// classifies the response so the UI can render an inline + /// state line under the password field. + private enum FaucetValidationStatus: Equatable { + case idle + case checking + case valid + case invalid + case unreachable(String) + + var label: String? { + switch self { + case .idle: return nil + case .checking: return "Checking…" + case .valid: return "Authorized" + case .invalid: return "Wrong password" + case .unreachable(let r): return "Unreachable (\(r))" + } + } + + var systemImage: String? { + switch self { + case .idle: return nil + case .checking: return "ellipsis.circle" + case .valid: return "checkmark.circle.fill" + case .invalid: return "xmark.circle.fill" + case .unreachable: return "exclamationmark.triangle.fill" + } + } + + var color: Color { + switch self { + case .idle, .checking: return .secondary + case .valid: return .green + case .invalid: return .red + case .unreachable: return .orange + } + } + } + // Bind the SPV peer-override settings directly to the same // UserDefaults keys that CoreContentView reads when starting SPV // (`useLocalhostCore`, `localCorePeers`). This re-exposes the @@ -93,13 +145,42 @@ struct OptionsView: View { .help("Connect to local dashmate Docker network.") if appState.useDockerSetup { - TextField("Faucet RPC Password", text: Binding( - get: { UserDefaults.standard.string(forKey: "faucetRPCPassword") ?? "" }, - set: { UserDefaults.standard.set($0, forKey: "faucetRPCPassword") } - )) - .font(.system(.body, design: .monospaced)) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() + TextField("Faucet RPC Password", text: $faucetPassword) + .font(.system(.body, design: .monospaced)) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .onChange(of: faucetPassword) { _, newValue in + UserDefaults.standard.set(newValue, forKey: "faucetRPCPassword") + } + // Debounce keystrokes via `.task(id:)`: every + // edit cancels the prior task (the sleep + // throws `CancellationError`), so validation + // only fires when the user pauses for 0.5s. + .task(id: faucetPassword) { + do { + try await Task.sleep(nanoseconds: 500_000_000) + } catch { + return + } + await validateFaucetPassword(faucetPassword) + } + + if let label = faucetValidation.label, + let icon = faucetValidation.systemImage { + HStack(spacing: 6) { + if faucetValidation == .checking { + ProgressView() + .scaleEffect(0.7) + } else { + Image(systemName: icon) + .foregroundColor(faucetValidation.color) + } + Text(label) + .font(.caption) + .foregroundColor(faucetValidation.color) + Spacer() + } + } } } else { Toggle("Use Custom SPV Peers", isOn: $customSpvPeersEnabled) @@ -313,6 +394,87 @@ struct OptionsView: View { } } + /// Validate `password` against the dashmate-managed Core RPC. + /// + /// Issues a single `getblockcount` JSON-RPC call to + /// `http://127.0.0.1:/` with HTTP basic auth and + /// classifies the response into the `FaucetValidationStatus` + /// the UI renders. Empty password short-circuits to `.idle` so + /// the inline status row stays hidden until the user types. + /// + /// Called from the password field's `.task(id: faucetPassword)`, + /// which already provides the 0.5s debounce — repeat keystrokes + /// auto-cancel the in-flight task before it gets here. + @MainActor + private func validateFaucetPassword(_ password: String) async { + guard !password.isEmpty else { + faucetValidation = .idle + return + } + faucetValidation = .checking + + let rpcPort = UserDefaults.standard.string(forKey: "faucetRPCPort") ?? "20302" + let rpcUser = UserDefaults.standard.string(forKey: "faucetRPCUser") ?? "dashmate" + + guard let url = URL(string: "http://127.0.0.1:\(rpcPort)/") else { + faucetValidation = .unreachable("invalid URL") + return + } + + let body: [String: Any] = [ + "jsonrpc": "1.0", + "id": "validate", + "method": "getblockcount", + "params": [] + ] + guard let jsonData = try? JSONSerialization.data(withJSONObject: body) else { + faucetValidation = .unreachable("encode failed") + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.httpBody = jsonData + request.setValue("text/plain", forHTTPHeaderField: "Content-Type") + // Short timeout — Docker not running surfaces as a fast + // failure rather than a hung "Checking…" spinner. + request.timeoutInterval = 3 + + let credentials = "\(rpcUser):\(password)" + if let credData = credentials.data(using: .utf8) { + request.setValue( + "Basic \(credData.base64EncodedString())", + forHTTPHeaderField: "Authorization" + ) + } + + do { + let (_, response) = try await URLSession.shared.data(for: request) + // Bail if the user kept typing while we were waiting on + // the network — the next `.task(id:)` invocation will + // re-classify with the fresh value. + guard !Task.isCancelled else { return } + guard let http = response as? HTTPURLResponse else { + faucetValidation = .unreachable("invalid response") + return + } + switch http.statusCode { + case 200: + faucetValidation = .valid + case 401, 403: + faucetValidation = .invalid + default: + faucetValidation = .unreachable("HTTP \(http.statusCode)") + } + } catch is CancellationError { + // Superseded by a fresher keystroke; the new task will + // produce the next status. + return + } catch { + faucetValidation = .unreachable(error.localizedDescription) + } + } + /// Fetch the SDK version / network / mode / quorum count for /// display in the Platform section. Called once on appear and /// on demand via the refresh button. From 0cacadb4ba43cf74eedce42a3aca447899b11c49 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Tue, 5 May 2026 20:21:34 +0700 Subject: [PATCH 19/34] fix(swift-example-app): point regtest+docker SPV at dashmate seed port (#3589) Co-authored-by: Claude Opus 4.7 (1M context) --- .../Core/Views/CoreContentView.swift | 45 ++++++++++++++++--- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index 96bb249295..c49b1fbb76 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift @@ -484,18 +484,14 @@ var body: some View { .appendingPathComponent(platformState.currentNetwork.networkName) try? FileManager.default.createDirectory(at: dataDirURL, withIntermediateDirectories: true) - let useLocalCore = UserDefaults.standard.bool(forKey: "useLocalhostCore") - let peers: [String] = useLocalCore - ? ((UserDefaults.standard.string(forKey: "localCorePeers") ?? "127.0.0.1") - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespaces) }) - : [] + let peers = spvPeerOverride() + let restrictToConfiguredPeers = !peers.isEmpty let config = PlatformSpvStartConfig( dataDir: dataDirURL.path, network: platformState.currentNetwork, peers: peers, - restrictToConfiguredPeers: useLocalCore, + restrictToConfiguredPeers: restrictToConfiguredPeers, masternodeSyncEnabled: masternodesEnabled ) try walletManager.startSpv(config: config) @@ -504,6 +500,41 @@ var body: some View { } } + /// Resolve the SPV peer override for the current network / + /// docker combo. + /// + /// Three modes coexist on top of the same `useLocalhostCore` / + /// `localCorePeers` `UserDefaults` keys, which used to bleed into + /// each other when the user reconfigured between sessions: + /// + /// 1. **regtest + docker** — connect to dashmate's `local_seed` + /// Core P2P port. The default 3-node setup maps the seed to + /// `127.0.0.1:20301` (`getLocalConfigFactory.js` base 20001 + /// + `setupLocalPresetTaskFactory.js` `+ i*100` with seed + /// at index = `nodeCount`, typically 3). Anything sitting + /// in `localCorePeers` from a previous testnet / mainnet + /// "custom peers" session is ignored — the UI doesn't show + /// that knob on regtest+docker so a stale value is always + /// bleed-through, never user intent. + /// 2. **non-regtest + custom peers** — honor `localCorePeers` + /// verbatim. The OptionsView "Use Custom SPV Peers" toggle + /// seeds and edits this string. + /// 3. **everything else** — empty list, FFI uses the network's + /// built-in seed nodes. + private func spvPeerOverride() -> [String] { + let useDocker = UserDefaults.standard.bool(forKey: "useDockerSetup") + if platformState.currentNetwork == .regtest && useDocker { + return ["127.0.0.1:20301"] + } + let useLocalCore = UserDefaults.standard.bool(forKey: "useLocalhostCore") + guard useLocalCore else { return [] } + let raw = UserDefaults.standard.string(forKey: "localCorePeers") ?? "" + return raw + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + } + private func pauseSync() { try? walletManager.stopSpv() } From b9a9293e1ac199c07c517d19d4d3a4e3bd794ced Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 5 May 2026 15:22:19 +0200 Subject: [PATCH 20/34] feat(rs-platform-wallet/e2e): add wait_for_bank_funded framework gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Helper polls BankWallet::core_balance_confirmed until it reaches the configured floor or the timeout elapses, and emits an info-level progress line every 30s including the SPV compact-filter scan height vs the chain tip — operator can tell "scan still walking" from "scan at tip, balance genuinely zero". Adds Config::bank_core_gate_duffs (env: PLATFORM_WALLET_E2E_BANK_CORE_GATE, default 0 = skip). CR-* / ID-007 cases raise this floor to gate harness init on the bank's pre-funded UTXOs being visible to SPV — Marvin's QA-001: a cold-cache run on testnet samples core_balance ~52s in while SPV is still ~15min from completing the genesis-to-tip filter walk and a CR-003 / ID-007 send_core_to fails on a false-zero balance. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tests/e2e/framework/config.rs | 30 +++++ .../tests/e2e/framework/mod.rs | 4 +- .../tests/e2e/framework/wait.rs | 118 ++++++++++++++++++ 3 files changed, 151 insertions(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet/tests/e2e/framework/config.rs b/packages/rs-platform-wallet/tests/e2e/framework/config.rs index 0dee682057..bed29ca5f6 100644 --- a/packages/rs-platform-wallet/tests/e2e/framework/config.rs +++ b/packages/rs-platform-wallet/tests/e2e/framework/config.rs @@ -41,6 +41,11 @@ pub mod vars { /// bank's first platform address on first run and persist its id /// to the workdir slot". pub const BANK_IDENTITY_ID: &str = "PLATFORM_WALLET_E2E_BANK_IDENTITY_ID"; + /// Optional minimum bank Core (Layer-1) balance, in duffs, that + /// the harness waits for before flagging the bank as ready. `0` + /// (default) skips the gate; CR-* / ID-007-class cases that need + /// Core duffs raise the floor and accept the cold-cache wait. + pub const BANK_CORE_GATE: &str = "PLATFORM_WALLET_E2E_BANK_CORE_GATE"; } /// Default minimum bank balance in credits. @@ -91,6 +96,11 @@ pub struct Config { /// auto-registers a bank identity on first run and persists its /// id under the workdir slot. pub bank_identity_id: Option, + /// Minimum bank Core (Layer-1) balance, in duffs, the harness + /// gates on before completing init. `0` (default) skips the gate. + /// CR-* / ID-007-class operators raise this floor and accept the + /// cold-cache compact-filter scan wait. + pub bank_core_gate_duffs: u64, } impl std::fmt::Debug for Config { @@ -106,6 +116,7 @@ impl std::fmt::Debug for Config { .field("trusted_context_url", &self.trusted_context_url) .field("p2p_port", &self.p2p_port) .field("bank_identity_id", &self.bank_identity_id) + .field("bank_core_gate_duffs", &self.bank_core_gate_duffs) .finish() } } @@ -122,6 +133,7 @@ impl Default for Config { trusted_context_url: None, p2p_port: default_p2p_port(network), bank_identity_id: None, + bank_core_gate_duffs: 0, } } } @@ -209,6 +221,23 @@ impl Config { .map(|raw| raw.trim().to_string()) .filter(|s| !s.is_empty()); + let bank_core_gate_duffs = match std::env::var(vars::BANK_CORE_GATE) { + Ok(raw) => { + let trimmed = raw.trim(); + if trimmed.is_empty() { + 0 + } else { + trimmed.parse::().map_err(|err| { + FrameworkError::Config(format!( + "{} = {raw:?} is not a valid u64: {err}", + vars::BANK_CORE_GATE + )) + })? + } + } + Err(_) => 0, + }; + Ok(Self { bank_mnemonic, network, @@ -218,6 +247,7 @@ impl Config { trusted_context_url, p2p_port, bank_identity_id, + bank_core_gate_duffs, }) } diff --git a/packages/rs-platform-wallet/tests/e2e/framework/mod.rs b/packages/rs-platform-wallet/tests/e2e/framework/mod.rs index 0a2a57db51..10a5e95a36 100644 --- a/packages/rs-platform-wallet/tests/e2e/framework/mod.rs +++ b/packages/rs-platform-wallet/tests/e2e/framework/mod.rs @@ -67,7 +67,9 @@ pub(super) fn make_platform_signer( pub mod prelude { pub use super::config::Config; pub use super::harness::E2eContext; - pub use super::wait::{wait_for, wait_for_balance, wait_for_core_balance}; + pub use super::wait::{ + wait_for, wait_for_balance, wait_for_bank_funded, wait_for_core_balance, + }; pub use super::wait_hub::WaitEventHub; pub use super::{setup, FrameworkError, FrameworkResult, SetupGuard}; } diff --git a/packages/rs-platform-wallet/tests/e2e/framework/wait.rs b/packages/rs-platform-wallet/tests/e2e/framework/wait.rs index b9b4e97366..f10c18699c 100644 --- a/packages/rs-platform-wallet/tests/e2e/framework/wait.rs +++ b/packages/rs-platform-wallet/tests/e2e/framework/wait.rs @@ -11,13 +11,16 @@ use std::time::{Duration, Instant}; use dash_sdk::platform::Fetch; use dash_sdk::Sdk; +use dash_spv::sync::ProgressPercentage; use dpp::address_funds::PlatformAddress; use dpp::fee::Credits; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::Identity; use dpp::prelude::Identifier; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; +use platform_wallet::SpvRuntime; +use super::bank::BankWallet; use super::wallet_factory::TestWallet; use super::{FrameworkError, FrameworkResult}; @@ -184,6 +187,121 @@ pub async fn wait_for_core_balance( } } +/// Wait for the bank wallet's confirmed Core (Layer-1) balance to +/// reach at least `min_duffs`. +/// +/// Used by the harness right after [`BankWallet::load`] to gate the +/// "ready to issue Core sends" milestone on the SPV compact-filter +/// scan having actually walked far enough to observe the bank's +/// pre-funded UTXOs (Marvin's QA-001 — without this gate, a cold-cache +/// run samples the balance while SPV is still ~52 s into a ~15 min +/// scan and reports `confirmed=0` for an address that's been funded +/// since last week). +/// +/// Polls [`BankWallet::core_balance_confirmed`] every +/// [`BACKSTOP_WAKE_INTERVAL`] until the threshold is met. Emits a +/// progress log every [`BANK_FUNDED_PROGRESS_INTERVAL`] including the +/// SPV filter-scan height vs the chain tip — operators can tell +/// "scan at 1.2M of 1.47M, still walking" (alive) from "scan at tip, +/// balance still 0" (real funding problem). Returns the observed +/// balance on success, [`FrameworkError::Cleanup`] on timeout. +pub async fn wait_for_bank_funded( + bank: &BankWallet, + spv: Option<&SpvRuntime>, + min_duffs: u64, + timeout: Duration, +) -> FrameworkResult { + let start = Instant::now(); + let deadline = start + timeout; + let mut next_progress_log = start + BANK_FUNDED_PROGRESS_INTERVAL; + + loop { + let observed = bank.core_balance_confirmed(); + if observed >= min_duffs { + tracing::info!( + target: "platform_wallet::e2e::wait", + observed, + min_duffs, + elapsed = ?start.elapsed(), + "bank Core funding gate cleared" + ); + return Ok(observed); + } + + let now = Instant::now(); + if now >= next_progress_log { + log_bank_funded_progress(spv, observed, min_duffs, start.elapsed()).await; + next_progress_log = now + BANK_FUNDED_PROGRESS_INTERVAL; + } + + let remaining = deadline.saturating_duration_since(now); + if remaining.is_zero() { + log_bank_funded_progress(spv, observed, min_duffs, start.elapsed()).await; + return Err(FrameworkError::Cleanup(format!( + "wait_for_bank_funded timed out after {timeout:?} \ + (observed={observed} duffs, min_duffs={min_duffs})" + ))); + } + tokio::time::sleep(std::cmp::min(remaining, BACKSTOP_WAKE_INTERVAL)).await; + } +} + +/// Period between info-level progress lines emitted by +/// [`wait_for_bank_funded`]. +pub const BANK_FUNDED_PROGRESS_INTERVAL: Duration = Duration::from_secs(30); + +/// One info-level progress line for [`wait_for_bank_funded`]. Pulls +/// the SPV filter-scan height + tip when the runtime is available so +/// the operator can distinguish "scan still walking" from "scan at +/// tip, balance genuinely zero". +async fn log_bank_funded_progress( + spv: Option<&SpvRuntime>, + observed: u64, + target: u64, + elapsed: Duration, +) { + let snapshot = match spv { + Some(rt) => rt.sync_progress().await, + None => None, + }; + let filters = snapshot + .as_ref() + .and_then(|p| p.filters().ok()) + .map(|f| (f.current_height(), f.target_height())); + let headers = snapshot + .as_ref() + .and_then(|p| p.headers().ok()) + .map(|h| (h.current_height(), h.target_height())); + + match (filters, headers) { + (Some((scan_height, scan_tip)), _) => tracing::info!( + target: "platform_wallet::e2e::wait", + observed, + target, + scan_height, + scan_tip, + ?elapsed, + "waiting for bank Core funding (SPV compact-filter scan in progress)" + ), + (None, Some((tip, target_tip))) => tracing::info!( + target: "platform_wallet::e2e::wait", + observed, + target, + header_height = tip, + header_tip = target_tip, + ?elapsed, + "waiting for bank Core funding (filters not yet reporting; headers shown)" + ), + (None, None) => tracing::info!( + target: "platform_wallet::e2e::wait", + observed, + target, + ?elapsed, + "waiting for bank Core funding (no SPV progress snapshot yet)" + ), + } +} + /// Wait for an on-chain identity balance to reach at least `expected`. /// /// Polls `Identity::fetch(sdk, identity_id)` every From 7760040ef6332a74e681df3c19dca96ad961b26f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 5 May 2026 15:22:30 +0200 Subject: [PATCH 21/34] feat(rs-platform-wallet/e2e): gate bank operations on cold-cache filter scan completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Calls wait_for_bank_funded between BankWallet::load and the bank Core address banner so the banner reflects the post-scan balance instead of a false-zero mid-scan (Marvin's QA-001). Default gate is 0 — most tests don't need duffs and the wait is wasted; CR-* / ID-007 operators raise it via PLATFORM_WALLET_E2E_BANK_CORE_GATE and accept the cold-cache ~15min wait for the first run (subsequent runs reuse the on-disk SPV cache and clear in seconds). Gate failure is demoted to a warn rather than a hard abort so unrelated tests still run; tests that need bank Core funding panic at send_core_to with the operator-actionable "top up at " message. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tests/e2e/framework/harness.rs | 73 +++++++++++++++++-- 1 file changed, 65 insertions(+), 8 deletions(-) diff --git a/packages/rs-platform-wallet/tests/e2e/framework/harness.rs b/packages/rs-platform-wallet/tests/e2e/framework/harness.rs index 60ea5d1bfc..2a6e7d387a 100644 --- a/packages/rs-platform-wallet/tests/e2e/framework/harness.rs +++ b/packages/rs-platform-wallet/tests/e2e/framework/harness.rs @@ -26,6 +26,7 @@ use super::config::Config; use super::registry::PersistentTestWalletRegistry; use super::sdk; use super::spv; +use super::wait; use super::wait_hub::WaitEventHub; use super::workdir; use super::FrameworkResult; @@ -35,6 +36,15 @@ use super::FrameworkResult; /// [`spv::wait_for_mn_list_synced`] so cold testnet caches still fit. const SPV_READY_TIMEOUT: Duration = Duration::from_secs(180); +/// Deadline for the bank's confirmed Core balance to reach +/// [`Config::bank_core_gate_duffs`]. Sized to fit a cold-cache compact- +/// filter scan from genesis on testnet (~1.47M blocks ≈ 15 min); +/// subsequent runs reuse the on-disk cache and clear the gate in +/// seconds. Marvin's QA-001 — without this gate, a cold-cache process +/// samples the balance ~52 s in and reports `confirmed=0` for an +/// address that's been funded since last week. +const BANK_CORE_FUNDING_TIMEOUT: Duration = Duration::from_secs(900); + /// Process-shared singleton populated on first /// [`E2eContext::init`]. static CTX: OnceCell = OnceCell::const_new(); @@ -157,16 +167,63 @@ impl E2eContext { // Panics on under-funded balance — see `BankWallet::load`. let bank = BankWallet::load(&manager, &config).await?; + // Bank Core (Layer-1) funding gate. Marvin's QA-001 — first + // cold-cache run on testnet walks ~1.47M compact filters from + // genesis (~15 min); without the gate, the harness samples + // `core_balance_confirmed` while the scan is still ~52 s in + // and any CR-* / ID-007 case using `send_core_to` fails on a + // false-zero balance. `bank_core_gate_duffs == 0` (default) + // skips the gate — most tests don't need duffs and the cold- + // cache wait is wasted. Operators raise the floor via + // `PLATFORM_WALLET_E2E_BANK_CORE_GATE` when running CR-* / + // ID-007 cases. + // + // Failure is demoted to a warn rather than a hard abort so + // tests that don't need bank Core funding still run; the ones + // that do panic at `send_core_to` with the operator-actionable + // "top up at " message (see `BankWallet::send_core_to`). + if config.bank_core_gate_duffs > 0 { + tracing::info!( + target: "platform_wallet::e2e::bank", + gate_duffs = config.bank_core_gate_duffs, + timeout = ?BANK_CORE_FUNDING_TIMEOUT, + "waiting for bank Core funding gate (first cold-cache run \ + takes ~15 min while SPV walks compact filters from genesis; \ + subsequent runs reuse the on-disk cache and complete in seconds)" + ); + match wait::wait_for_bank_funded( + &bank, + spv_runtime.as_deref(), + config.bank_core_gate_duffs, + BANK_CORE_FUNDING_TIMEOUT, + ) + .await + { + Ok(observed) => tracing::info!( + target: "platform_wallet::e2e::bank", + observed, + gate_duffs = config.bank_core_gate_duffs, + "bank Core funding gate cleared" + ), + Err(err) => tracing::warn!( + target: "platform_wallet::e2e::bank", + error = %err, + "bank Core funding gate timed out; tests requiring \ + bank Core funding will surface BankCoreUnderfunded with \ + the operator-actionable top-up address" + ), + } + } + // Surface the bank's Core (Layer-1) balance and primary // receive address at init with a visual marker so it's easy - // to spot in test output. Most tests don't need duffs — a - // zero balance is not fatal — but CR-/ID-007-class cases - // require the address to be pre-funded with testnet duffs - // before they can run end-to-end. Logged once per process so - // funding the bank is a single-line copy-paste task. Errors - // fetching the address are demoted to a warning so framework - // init isn't gated on Core paths that most tests bypass - // entirely. + // to spot in test output. Logged AFTER the gate above so the + // banner reflects the post-scan balance — Marvin's QA-001 + // (a pre-gate banner shows `core_balance_balance=0` while + // SPV is mid-scan, which sends operators chasing a phantom + // funding problem). Errors fetching the address are demoted + // to a warning so framework init isn't gated on Core paths + // that most tests bypass entirely. // QA-003: surface the bank's `birth_height` next to the // address + balance so operators can tell "wallet starts // above your funding tx" from "your tx hasn't confirmed yet". From 717e6c1e25164691e915324ecc5d47ee741aedf7 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 5 May 2026 15:22:38 +0200 Subject: [PATCH 22/34] docs(rs-platform-wallet/e2e): document cold-cache scan time on ID-007 + CR-003 Adds an "Operator notes" line on each entry recording the ~15min cold- cache compact-filter scan, the PLATFORM_WALLET_E2E_BANK_CORE_GATE env var the operator sets to gate harness init on the post-scan balance, and the RUST_LOG target that surfaces scan-progress lines (Marvin's QA-002). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md b/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md index 38a8a4a4ec..4ce1be1a89 100644 --- a/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md +++ b/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md @@ -927,6 +927,7 @@ Counts by priority: **P0: 10**, **P1: 24** (incl. 2 post-Task #15), **P2: 57** ( - **Rationale**: Pins the wallet's contract for "which DIP-9 subfeatures get monitored?" The closed PR `dashpay/rust-dashcore#554` user story explicitly called out identity-auth addresses as a scenario it wanted SPV-monitored; the PR is closed without merge or supersede pointer, and the current contract in the pinned `key-wallet` rev silently excludes them. ID-007 makes that exclusion an asserted contract so that: 1. anyone who flips `WalletAccountCreationOptions::Default` to include `BlockchainIdentities*` accounts (or any equivalent reshape upstream) breaks this test loudly, and the assertion bodies can be flipped in the same PR; 2. nobody on the platform side accidentally relies on the monitored-addresses set covering identity-auth addresses before the upstream story lands. +- **Operator notes**: First cold-cache run takes ~15 minutes because SPV walks compact filters from genesis (~1.47M testnet blocks). Subsequent runs reuse the on-disk cache and complete in seconds. The harness gates init on `PLATFORM_WALLET_E2E_BANK_CORE_GATE` (default `0` — skip); set it to at least `110_000` (`100_000` send + `~10_000` fee reserve) before invoking ID-007 so the bank's `core_balance_confirmed` reflects the post-scan total instead of a false-zero mid-scan. Set `RUST_LOG=info,platform_wallet::e2e::wait=info` to see scan-progress lines (`scan_height` vs `scan_tip`) every 30s. - **Notes**: - Today `derive_ecdsa_identity_auth_keypair_from_master` is the only DIP-9 subfeature `rs-platform-wallet` exposes (subfeature 0, ECDSA). Adding the BLS / Hash160 negative variants is contingent on the upstream `key-wallet` API gaining BLS derivation helpers. - This is a **defensive contract pin**, not a feature test. Same shape as `Found-003` / `Found-004` — pin a known-incomplete behaviour as the contract until someone explicitly extends it. @@ -1360,6 +1361,7 @@ so that when SPV lands, the test bodies can be written without further design. - **Harness extensions required**: faucet adapter; Core-funded wallet helper. - **Estimated complexity**: L - **Rationale**: Mirrors DET's existing canonical Identity-create coverage. Lower priority than ID-001 because address-funded is the path with no other coverage in the workspace. +- **Operator notes**: First cold-cache run takes ~15 minutes because SPV walks compact filters from genesis (~1.47M testnet blocks). Subsequent runs reuse the on-disk cache and complete in seconds. The harness gates init on `PLATFORM_WALLET_E2E_BANK_CORE_GATE` (default `0` — skip); set it to at least `TEST_WALLET_CORE_FUNDING + CORE_TX_FEE_RESERVE` (≈ `200_010_000` duffs) before invoking CR-003 so the bank's `core_balance_confirmed` reflects the post-scan total instead of a false-zero mid-scan. Set `RUST_LOG=info,platform_wallet::e2e::wait=info` to see scan-progress lines (`scan_height` vs `scan_tip`) every 30s. ### Contracts (CT) From d507193c523e328ff4cada49f4b89e48d7010198 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Tue, 5 May 2026 20:24:41 +0700 Subject: [PATCH 23/34] ci: skip @dashevo/wasm-dpp tests on pull_request (nightly-only) (#3593) Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/tests.yml | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d679990f4f..7ebf849572 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -42,7 +42,7 @@ jobs: if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || !github.event.pull_request.draft }} runs-on: ubuntu-24.04 outputs: - js-packages: ${{ steps.override.outputs.js-packages || steps.filter-js.outputs.changes }} + js-packages: ${{ steps.override.outputs.js-packages || steps.prune-pr-matrix.outputs.js-packages || steps.filter-js.outputs.changes }} js-packages-direct: ${{ steps.override.outputs.js-packages-direct || steps.filter-js-direct.outputs.changes }} rs-packages: ${{ steps.override.outputs.rs-packages || steps.filter-rs.outputs.changes }} rs-workflows-changed: ${{ steps.filter-rs-workflows.outputs.rs-workflows }} @@ -124,6 +124,35 @@ jobs: - packages/rs-sdk/** - packages/rs-sdk-ffi/** + # Drop @dashevo/wasm-dpp from the JS test matrix on + # `pull_request` events so the heaviest entry in the matrix + # only runs on the nightly schedule + manual + # `workflow_dispatch`. @dashevo/wasm-dpp2 stays on the PR + # path — it's a separate, lighter package that should be + # exercised on every PR. Cascading consumers + # (`dapi-client`, `wallet-lib`, `dash`, `dashmate`, + # `platform-test-suite`, `evo-sdk`) also keep running on PRs: + # their JS test code exercises behavior on top of wasm-dpp's + # already-built artifact, and `tests-build-js.yml` runs + # `yarn build` across the whole workspace so the wasm-dpp + # output is still compiled and linkable for them — just not + # test-driven on the PR critical path. To force wasm-dpp + # tests on a specific PR, use the `Run workflow` + # (workflow_dispatch) button on the Actions tab — that path + # goes through the `override` step below and runs every JS + # package. + - name: Skip wasm-dpp tests on pull_request (nightly-only) + id: prune-pr-matrix + if: ${{ github.event_name == 'pull_request' }} + run: | + set -eo pipefail + raw='${{ steps.filter-js.outputs.changes }}' + pruned=$(echo "$raw" | jq -c 'map(select(. != "@dashevo/wasm-dpp"))') + echo "js-packages=$pruned" >> "$GITHUB_OUTPUT" + echo "Pruned wasm-dpp from PR matrix:" + echo " before: $raw" + echo " after: $pruned" + - name: Override all outputs for workflow_dispatch id: override if: ${{ github.event_name == 'workflow_dispatch' }} From 47359641f2078460ab7b8c8c63956fbc449a3e4a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 5 May 2026 16:00:02 +0200 Subject: [PATCH 24/34] feat(rs-platform-wallet/e2e): surface dash-spv ManagerError early in wait_for_mn_list_synced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously wait_for_mn_list_synced only polled the mn-list snapshot and burned the full 600s cold-cache floor when dash-spv had already given up internally — most commonly when the QRInfo retry loop hard-caps at 3 attempts with "Required rotated chain lock sig at h - 0 not present". Two complementary signals now short-circuit the wait: 1. Event-driven: register a single-purpose PlatformEventHandler on the SpvRuntime's event manager that forwards SyncEvent::ManagerError (scoped to ManagerIdentifier::Masternode) into an mpsc channel. The wait loop selects on this channel ahead of the poll tick, so the engine signal surfaces in O(ms) with an operator-actionable error message ("wipe spv-data/, or wait 10-20 min for the next testnet ChainLock cycle"). 2. Heuristic backstop: if the engine ever stops emitting the error (e.g. silent retry loop), the wait still bails after 120s of no forward progress on the mn-list snapshot. A thin pass-through accessor SpvRuntime::event_manager() is added so the framework can subscribe without touching dash-spv internals. Effect: a known-stalled run that used to wait 600s now bails in well under 120s — the event path typically in ~1s after dash-spv emits ManagerError. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rs-platform-wallet/src/spv/runtime.rs | 11 ++ .../tests/e2e/framework/spv.rs | 162 +++++++++++++++++- 2 files changed, 164 insertions(+), 9 deletions(-) diff --git a/packages/rs-platform-wallet/src/spv/runtime.rs b/packages/rs-platform-wallet/src/spv/runtime.rs index eecb0e5860..2e3d8daa40 100644 --- a/packages/rs-platform-wallet/src/spv/runtime.rs +++ b/packages/rs-platform-wallet/src/spv/runtime.rs @@ -233,6 +233,17 @@ impl SpvRuntime { Some(client.sync_progress().await) } + /// The [`PlatformEventManager`] this runtime dispatches SPV events + /// through. Exposed so consumers (e.g. the e2e framework) can + /// register additional [`crate::events::PlatformEventHandler`]s + /// after construction — for example, to observe + /// `SyncEvent::ManagerError` while waiting for mn-list sync so + /// hard-stalls surface immediately instead of burning the full + /// timeout. + pub fn event_manager(&self) -> &Arc { + &self.event_manager + } + /// Clear all persisted SPV storage (headers, filters, state). /// /// The SPV client must be running to perform this operation. diff --git a/packages/rs-platform-wallet/tests/e2e/framework/spv.rs b/packages/rs-platform-wallet/tests/e2e/framework/spv.rs index 066037713d..c479d3ad4e 100644 --- a/packages/rs-platform-wallet/tests/e2e/framework/spv.rs +++ b/packages/rs-platform-wallet/tests/e2e/framework/spv.rs @@ -18,11 +18,14 @@ use std::time::{Duration, Instant}; use dash_sdk::dapi_client::AddressList; use dash_spv::client::config::MempoolStrategy; -use dash_spv::sync::{ProgressPercentage, SyncState}; +use dash_spv::network::NetworkEvent; +use dash_spv::sync::{ManagerIdentifier, ProgressPercentage, SyncEvent, SyncState}; use dash_spv::types::ValidationMode; use dash_spv::ClientConfig; use dashcore::Network; +use platform_wallet::events::{EventHandler, PlatformEventHandler, WalletEvent}; use platform_wallet::{changeset::PlatformWalletPersistence, PlatformWalletManager, SpvRuntime}; +use tokio::sync::mpsc; use super::config::Config; use super::{FrameworkError, FrameworkResult}; @@ -38,6 +41,17 @@ const COLD_CACHE_TIMEOUT_FLOOR: Duration = Duration::from_secs(600); /// Period for "still waiting" progress logs. const PROGRESS_LOG_INTERVAL: Duration = Duration::from_secs(30); +/// Mn-list-stall heuristic: if the mn-list snapshot does not change +/// (state + current_height + target_height all identical) for this +/// long while we're still waiting, dash-spv has almost certainly +/// given up internally — fail fast instead of burning the cold-cache +/// floor. Backstop for the event-driven `ManagerError` path: if +/// dash-spv ever stops emitting that event for the same root cause, +/// we still bail in well under the 600s floor. 120s ≈ 2 min ≈ +/// roughly the testnet block interval, so a single missed block tick +/// won't trip it. +const MN_LIST_STALL_THRESHOLD: Duration = Duration::from_secs(120); + /// Spawn the SPV client backing the harness's /// [`PlatformWalletManager`]. Storage is anchored under /// `/spv-data` where `workdir` is the slot the harness @@ -74,11 +88,26 @@ where Ok(spv) } -/// Block until the SPV mn-list manager reports `Synced`, or the -/// effective timeout (`timeout.max(COLD_CACHE_TIMEOUT_FLOOR)`) -/// elapses. Polls every [`READINESS_POLL_INTERVAL`] and emits an -/// info-level pipeline snapshot every [`PROGRESS_LOG_INTERVAL`] so -/// cold-cache hangs are debuggable from default-level logs. +/// Block until the SPV mn-list manager reports `Synced`, or one of +/// three failure conditions trips: +/// +/// 1. **Engine event** — dash-spv emits a +/// [`SyncEvent::ManagerError`] for the masternode manager. The +/// classic example is the QRInfo retry loop hard-capping at 3 +/// attempts (`Required rotated chain lock sig at h - 0 not +/// present`); the engine then stops trying to advance mn-list. We +/// bail with a sharply-targeted error message rather than burn +/// the full cold-cache floor. +/// 2. **Stall heuristic** — the mn-list snapshot has not advanced +/// (same state + current_height + target_height) for +/// [`MN_LIST_STALL_THRESHOLD`]. Backstop for cases where the +/// engine never emits a `ManagerError` (e.g. silent retry loop). +/// 3. **Hard timeout** — the effective timeout +/// (`timeout.max(COLD_CACHE_TIMEOUT_FLOOR)`) elapses. +/// +/// Polls every [`READINESS_POLL_INTERVAL`] and emits an info-level +/// pipeline snapshot every [`PROGRESS_LOG_INTERVAL`] so cold-cache +/// hangs are debuggable from default-level logs. pub async fn wait_for_mn_list_synced(spv: &SpvRuntime, timeout: Duration) -> FrameworkResult<()> { let effective_timeout = timeout.max(COLD_CACHE_TIMEOUT_FLOOR); if effective_timeout != timeout { @@ -90,13 +119,59 @@ pub async fn wait_for_mn_list_synced(spv: &SpvRuntime, timeout: Duration) -> Fra ); } + // Subscribe to dash-spv's `SyncEvent::ManagerError` stream by + // registering a single-purpose [`PlatformEventHandler`] on the + // runtime's event manager. The handler forwards Masternode-scoped + // errors into an mpsc channel that the wait loop selects on, so + // hard-stalls (QRInfo retry exhaustion, etc.) surface in O(ms) + // instead of waiting for the heuristic or the hard timeout. + // + // The handler stays registered for the lifetime of the + // `PlatformEventManager` (it has no `remove_handler`); after we + // return, sends on the channel become best-effort no-ops because + // the Receiver is dropped. That's a few harmless `Result::Err`s + // at most — never on the SPV hot path because Masternode errors + // are rare by design. + let (err_tx, mut err_rx) = mpsc::unbounded_channel::(); + let handler: Arc = Arc::new(MnListErrorListener::new(err_tx)) as _; + spv.event_manager().add_handler(handler); + let start = Instant::now(); let deadline = start + effective_timeout; let mut last_height: Option = None; let mut last_state: Option = None; + let mut last_target: Option = None; + let mut last_progress_at = start; let mut next_progress_log = start + PROGRESS_LOG_INTERVAL; loop { + // Race the engine error stream against the next poll tick. + // `biased` so a queued error wins over a coincident sleep + // expiry — surfaces the engine signal at the earliest tick. + tokio::select! { + biased; + maybe_err = err_rx.recv() => { + if let Some(err) = maybe_err { + tracing::error!( + target: "platform_wallet::e2e::spv", + error = %err, + elapsed = ?start.elapsed(), + "dash-spv reported ManagerError before mn-list synced" + ); + return Err(FrameworkError::Spv(format!( + "dash-spv reported ManagerError before mn-list synced: {err}. \ + Likely a stale workdir / testnet ChainLock cycle issue. \ + Try wiping spv-data/ and retry, or wait 10-20 min for the \ + next testnet ChainLock cycle." + ))); + } + // Sender dropped (shouldn't happen — we hold it via + // the registered handler). Fall through to a poll so + // the heuristic / hard timeout still applies. + } + _ = tokio::time::sleep(READINESS_POLL_INTERVAL) => {} + } + let progress = spv.sync_progress().await; let mn_snapshot = progress .as_ref() @@ -105,17 +180,23 @@ pub async fn wait_for_mn_list_synced(spv: &SpvRuntime, timeout: Duration) -> Fra if let Some(mn) = mn_snapshot.as_ref() { let height = mn.current_height(); let state = mn.state(); - if Some(height) != last_height || Some(state) != last_state { + let target = mn.target_height(); + let advanced = Some(height) != last_height + || Some(state) != last_state + || Some(target) != last_target; + if advanced { tracing::debug!( target: "platform_wallet::e2e::spv", state = ?state, current_height = height, - target_height = mn.target_height(), + target_height = target, elapsed = ?start.elapsed(), "mn-list sync progress" ); last_height = Some(height); last_state = Some(state); + last_target = Some(target); + last_progress_at = Instant::now(); } if matches!(state, SyncState::Synced) { tracing::info!( @@ -135,6 +216,33 @@ pub async fn wait_for_mn_list_synced(spv: &SpvRuntime, timeout: Duration) -> Fra "wait_for_mn_list_synced: mn-list entered Error state".to_string(), )); } + + // Heuristic: no forward progress for + // `MN_LIST_STALL_THRESHOLD` while still in a non-terminal + // state ⇒ engine is stuck. Bail with the same operator + // hint as the event path so the user sees one consistent + // remediation. + let stalled_for = last_progress_at.elapsed(); + if stalled_for >= MN_LIST_STALL_THRESHOLD { + log_pipeline_snapshot(progress.as_ref(), start.elapsed(), effective_timeout); + tracing::error!( + target: "platform_wallet::e2e::spv", + state = ?state, + current_height = height, + target_height = target, + stalled_for = ?stalled_for, + "mn-list sync made no forward progress for stall threshold; \ + engine has likely given up internally" + ); + return Err(FrameworkError::Spv(format!( + "wait_for_mn_list_synced: mn-list made no forward progress for \ + {stalled_for:?} (state={state:?}, current_height={height}, \ + target_height={target}). dash-spv has likely given up \ + internally without surfacing a ManagerError. \ + Try wiping spv-data/ and retry, or wait 10-20 min for the \ + next testnet ChainLock cycle." + ))); + } } // Periodic "still waiting" snapshot at info level so @@ -155,11 +263,47 @@ pub async fn wait_for_mn_list_synced(spv: &SpvRuntime, timeout: Duration) -> Fra "wait_for_mn_list_synced: timed out after {effective_timeout:?}" ))); } + } +} - tokio::time::sleep(READINESS_POLL_INTERVAL).await; +/// Single-purpose [`PlatformEventHandler`] that forwards +/// [`SyncEvent::ManagerError`] events scoped to +/// [`ManagerIdentifier::Masternode`] into an mpsc channel. Used by +/// [`wait_for_mn_list_synced`] to escape the cold-cache floor as +/// soon as dash-spv signals a fatal manager error. +/// +/// All other event variants are ignored — this is *not* a substitute +/// for [`super::wait_hub::WaitEventHub`]. +struct MnListErrorListener { + tx: mpsc::UnboundedSender, +} + +impl MnListErrorListener { + fn new(tx: mpsc::UnboundedSender) -> Self { + Self { tx } } } +impl EventHandler for MnListErrorListener { + fn on_sync_event(&self, event: &SyncEvent) { + if let SyncEvent::ManagerError { manager, error } = event { + if matches!(manager, ManagerIdentifier::Masternode) { + // Best-effort: receiver dropped after wait returned + // is fine, just means the event arrived too late to + // matter. + let _ = self.tx.send(format!("Masternode manager error: {error}")); + } + } + } + + fn on_network_event(&self, _event: &NetworkEvent) {} + fn on_progress(&self, _progress: &dash_spv::sync::SyncProgress) {} + fn on_wallet_event(&self, _event: &WalletEvent) {} + fn on_error(&self, _error: &str) {} +} + +impl PlatformEventHandler for MnListErrorListener {} + /// One-line info-level pipeline-snapshot log used by /// [`wait_for_mn_list_synced`]. fn log_pipeline_snapshot( From 9c62fd802fa14b349a414f6c6185b3e91de67a61 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 5 May 2026 16:00:11 +0200 Subject: [PATCH 25/34] docs(rs-platform-wallet/e2e): document mn-list QRInfo stall known issue Add a Known Issues / Operator Notes subsection (1.3) covering the dash-spv QRInfo retry-cap stall that wait_for_mn_list_synced now surfaces eagerly, with operator workaround steps (wait for next testnet ChainLock cycle, or wipe spv-data/). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rs-platform-wallet/tests/e2e/TEST_SPEC.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md b/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md index 4ce1be1a89..485c517668 100644 --- a/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md +++ b/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md @@ -62,6 +62,24 @@ BIP-39 mnemonic generator already used by `framework/wallet_factory.rs`. Cases that exercise non-ASCII content (e.g. Unicode display names) do so on downstream fields, not on the seed. +### 1.3 Known issues / operator notes + +**Known issue: dash-spv mn-list QRInfo stall.** When the workdir's +`masternodestate.json` cache is missing (first run or after wipe), and +the test starts near a testnet quorum rotation boundary, dash-spv's +QRInfo retry loop may hard-cap at 3 attempts with the error +`Required rotated chain lock sig at h - 0 not present`. The engine +then stops trying to advance mn-list. `wait_for_mn_list_synced` now +surfaces this immediately as `dash-spv reported ManagerError before +mn-list synced` (event-driven path) or as a no-forward-progress stall +after 120 s (heuristic backstop), instead of waiting the full 600 s +cold-cache floor. + +Operator workaround: wait 10–20 min for the next testnet ChainLock +cycle, then retry. If the issue persists, wipe +`${TMPDIR}/dash-platform-wallet-e2e/spv-data/` and retry from a clean +state. + --- ## 2. Harness capability matrix From 9df3aa4536f9a8a18d56e9627cca5248e879039f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 5 May 2026 17:15:34 +0200 Subject: [PATCH 26/34] docs(rs-platform-wallet/e2e): flip ID-007 to Pass after testnet pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ID-007 verified PASS on testnet at HEAD 32ee2cd6be after the dash-spv mn-list QRInfo retry-race window (cycle boundary 1471104, prior runs 1471135) cleared. Mn-list synced in 8.5s warm cache, bank Core funding gate cleared at 1_600_000_000 duffs (gate 110_000), identity EwATqMdBoCrDQoEBTwcammqAcGcKihzxGrW1qaLoDAJW registered, total wall-clock 130s. CR-003 not flipped: re-run failed at cr_003_asset_lock_funded_registration.rs:99 with "PRE-pin violated: setup_with_core_funded_test_wallet returned with confirmed Core balance 0 < TEST_WALLET_CORE_FUNDING 200000000". The helper observed mempool funds (target=200000000 reached at +2.0s) but the case's PRE-pin requires confirmed balance — meaningful contract mismatch, deferred for separate investigation. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md b/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md index 485c517668..bf0dc6d286 100644 --- a/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md +++ b/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md @@ -913,7 +913,7 @@ Counts by priority: **P0: 10**, **P1: 24** (incl. 2 post-Task #15), **P2: 57** ( #### ID-007 — Identity-auth addresses are visible to SPV monitor (BLOCKED on Task #15) - **Priority**: P2 -- **Status**: FRAMEWORK-READY — full test body implemented; `#[ignore]`-tagged. Framework prerequisites cleared: SPV runtime is live (Task #15 landed) and `BankWallet::send_core_to` is implemented (CR-003 — uses `CoreWallet::send_to_addresses` against the bank's BIP-44 account 0). End-to-end runs are gated on **operator pre-funding the bank's Core (Layer-1) receive address** with at least `100_000 + fee` duffs of testnet DASH. The address is logged at framework init (`platform_wallet::e2e::bank` target, `Bank Core (Layer-1) status core_balance_duffs core_address`); the same address surfaces in the `FrameworkError::Bank` "Bank Core under-funded" message if `send_core_to` is invoked with a zero balance. Tracks the scenario from closed PR `dashpay/rust-dashcore#554` (the parked attempt to ship `BlockchainIdentities*` AccountType variants and flip `WalletAccountCreationOptions::Default` to monitor those addresses) and DET follow-up issue `dash-evo-tool#692`. The wallet's contract today is "identity-auth addresses are NOT monitored"; this case pins that contract so any reshape upstream surfaces here rather than silently in DET or in user funds. +- **Status**: Pass — full test body implemented at `tests/e2e/cases/id_007_identity_auth_addresses_not_monitored.rs`; `#[ignore]`-tagged (testnet, gated on `PLATFORM_WALLET_E2E_BANK_CORE_GATE`). Verified PASS on testnet at HEAD `32ee2cd6be` (mn-list 8.5s warm cache, bank Core balance 1_600_000_000 duffs, gate 110_000, send `e8cabdecb187e58f74868ed021b37f17f4eed1b0bed63bb9696186f168471f26`, identity `EwATqMdBoCrDQoEBTwcammqAcGcKihzxGrW1qaLoDAJW`, total wall-clock 130s). Framework prerequisites cleared: SPV runtime is live (Task #15 landed) and `BankWallet::send_core_to` is implemented (CR-003 — uses `CoreWallet::send_to_addresses` against the bank's BIP-44 account 0). End-to-end runs are gated on **operator pre-funding the bank's Core (Layer-1) receive address** with at least `100_000 + fee` duffs of testnet DASH. The address is logged at framework init (`platform_wallet::e2e::bank` target, `Bank Core (Layer-1) status core_balance_duffs core_address`); the same address surfaces in the `FrameworkError::Bank` "Bank Core under-funded" message if `send_core_to` is invoked with a zero balance. Tracks the scenario from closed PR `dashpay/rust-dashcore#554` (the parked attempt to ship `BlockchainIdentities*` AccountType variants and flip `WalletAccountCreationOptions::Default` to monitor those addresses) and DET follow-up issue `dash-evo-tool#692`. The wallet's contract today is "identity-auth addresses are NOT monitored"; this case pins that contract so any reshape upstream surfaces here rather than silently in DET or in user funds. - **Wallet feature exercised**: `PlatformWalletInfo::monitored_addresses` (`wallet/platform_wallet_traits.rs:93`) projection for DIP-9 identity-authentication addresses derived via `derive_ecdsa_identity_auth_keypair_from_master` (`wallet/identity/network/identity_handle.rs:143`). Concretely: the `m/9'/coinType'/5'/0'/identity_index'/key_index'` subfeature path, which is not in `WalletAccountCreationOptions::Default` at the pinned `key-wallet` revision. - **DET parallel**: `dash-evo-tool#692` (the follow-up issue PR `dashpay/rust-dashcore#554` referenced for the DET-side `spv_account_metadata()` match arm). - **Preconditions**: From f3416dc602f0b17a31169cb49e02c5d2049c8600 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 5 May 2026 17:20:17 +0200 Subject: [PATCH 27/34] fix(rs-platform-wallet/e2e): wait_for_core_balance now requires confirmed balance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marvin QA-001 HIGH: setup_with_core_funded_test_wallet was returning ~2s after broadcast on mempool-only visibility, then CR-003's PRE-pin panicked because confirmed_core_balance was still 0. The waiter polled state().await.balance().spendable() (mempool-inclusive) while every caller — both the helper's own docstring and CR-003's asset-lock builder — needs *confirmed* UTXOs to reference. Switch wait_for_core_balance to poll TestWallet::core_balance_confirmed (the same lock-free atomic accessor the PRE-pin checks against), drop the stale state().await chain, and rewrite the doc comment to make the confirmed-only contract explicit. ID-007's pre_balance is updated in lockstep so the pin compares against the same metric the waiter reads — the timeout still fires because auth_addr_zero isn't in monitored_addresses(), independent of confirmation depth. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...7_identity_auth_addresses_not_monitored.rs | 14 +++---- .../tests/e2e/framework/wait.rs | 40 +++++++++---------- 2 files changed, 25 insertions(+), 29 deletions(-) diff --git a/packages/rs-platform-wallet/tests/e2e/cases/id_007_identity_auth_addresses_not_monitored.rs b/packages/rs-platform-wallet/tests/e2e/cases/id_007_identity_auth_addresses_not_monitored.rs index 644bc198cd..b974294575 100644 --- a/packages/rs-platform-wallet/tests/e2e/cases/id_007_identity_auth_addresses_not_monitored.rs +++ b/packages/rs-platform-wallet/tests/e2e/cases/id_007_identity_auth_addresses_not_monitored.rs @@ -147,14 +147,12 @@ async fn id_007_identity_auth_addresses_not_monitored() { // negative contract is "the wallet's monitored set never sees // this". The `wait_for_core_balance` call below is what bounds // observation of the (expected absent) UTXO. - let pre_balance = s - .base - .test_wallet - .platform_wallet() - .state() - .await - .balance() - .spendable(); + // Use the same lock-free confirmed-balance accessor that + // `wait_for_core_balance` polls — pinning `pre_balance + 1` against + // the same metric the waiter compares against keeps the negative + // contract crisp (the timeout fires because `auth_addr_zero` isn't + // in `monitored_addresses()`, not because the two readings drift). + let pre_balance = s.base.test_wallet.core_balance_confirmed(); let _txid = s .base .ctx diff --git a/packages/rs-platform-wallet/tests/e2e/framework/wait.rs b/packages/rs-platform-wallet/tests/e2e/framework/wait.rs index f10c18699c..4947c22e92 100644 --- a/packages/rs-platform-wallet/tests/e2e/framework/wait.rs +++ b/packages/rs-platform-wallet/tests/e2e/framework/wait.rs @@ -17,7 +17,6 @@ use dpp::fee::Credits; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::Identity; use dpp::prelude::Identifier; -use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use platform_wallet::SpvRuntime; use super::bank::BankWallet; @@ -127,23 +126,27 @@ pub async fn wait_for_balance( } } -/// Wait for the wallet's Layer-1 Core balance (in duffs) to reach at -/// least `expected_min`. +/// Wait for the wallet's Layer-1 Core *confirmed* balance (in duffs) +/// to reach at least `expected_min`. /// -/// Polls `test_wallet.platform_wallet().state().await.balance().spendable()` -/// every [`BACKSTOP_WAKE_INTERVAL`] until the threshold is met. The -/// SPV bloom-filter feed updates the underlying `WalletCoreBalance` -/// asynchronously, so a poll-based approach is sufficient — there's -/// no `Notified` future on the Core side analogous to -/// [`wait_for_balance`]'s wait hub. Returns -/// [`FrameworkError::Cleanup`] on `timeout`, the standard "did not -/// reach target in time" sentinel used by the other waiters. +/// Polls [`TestWallet::core_balance_confirmed`] — the lock-free atomic +/// fed by the SPV path's `WalletBalance::confirmed` — every +/// [`BACKSTOP_WAKE_INTERVAL`] until the threshold is met. Mempool / +/// instant-locked-but-unconfirmed UTXOs are deliberately NOT counted: +/// downstream callers (asset-lock construction in CR-003 onwards) need +/// confirmed UTXOs to reference, and a mempool-eager return would let +/// `setup_with_core_funded_test_wallet` hand back a wallet whose +/// `core_balance_confirmed()` is still 0. The SPV bloom-filter feed +/// updates the atomic asynchronously, so a poll-based approach is +/// sufficient — there's no `Notified` future on the Core side +/// analogous to [`wait_for_balance`]'s wait hub. Returns +/// [`FrameworkError::Cleanup`] on `timeout`. /// -/// Used by `ID-007` (pin: identity-auth addresses are NOT in +/// Used by [`super::setup_with_core_funded_test_wallet`] (positive +/// arrival on the test wallet's BIP-44 account 0) and by `ID-007` +/// (negative pin: identity-auth addresses are NOT in /// `monitored_addresses()`, so a Core send to one MUST time out -/// here at the pinned `key-wallet` revision); generally useful for -/// any future case asserting positive-balance arrival on a -/// monitored address. +/// here at the pinned `key-wallet` revision). pub async fn wait_for_core_balance( test_wallet: &TestWallet, expected_min: u64, @@ -153,12 +156,7 @@ pub async fn wait_for_core_balance( let deadline = Instant::now() + timeout; loop { - let observed = test_wallet - .platform_wallet() - .state() - .await - .balance() - .spendable(); + let observed = test_wallet.core_balance_confirmed(); if observed >= expected_min { tracing::info!( target: "platform_wallet::e2e::wait", From 409d088e5640b0db165acdd3b55941ab805ac083 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 5 May 2026 17:20:25 +0200 Subject: [PATCH 28/34] docs(rs-platform-wallet/e2e): drop stale BLOCKED parenthetical from ID-007 heading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marvin QA-002 LOW: TEST_SPEC heading still read "(BLOCKED on Task #15)" even though Task #15 has landed and ID-007 just passed end-to-end on testnet. The Status field on the same entry already records the PASS at HEAD 32ee2cd6be — the parenthetical is the only remaining stale marker. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md b/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md index bf0dc6d286..ae6113ed26 100644 --- a/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md +++ b/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md @@ -911,7 +911,7 @@ Counts by priority: **P0: 10**, **P1: 24** (incl. 2 post-Task #15), **P2: 57** ( - **Estimated complexity**: M - **Rationale**: ID-006 covers `identity_index` boundaries; `key_index` is the parallel axis and currently uncovered. -#### ID-007 — Identity-auth addresses are visible to SPV monitor (BLOCKED on Task #15) +#### ID-007 — Identity-auth addresses are visible to SPV monitor - **Priority**: P2 - **Status**: Pass — full test body implemented at `tests/e2e/cases/id_007_identity_auth_addresses_not_monitored.rs`; `#[ignore]`-tagged (testnet, gated on `PLATFORM_WALLET_E2E_BANK_CORE_GATE`). Verified PASS on testnet at HEAD `32ee2cd6be` (mn-list 8.5s warm cache, bank Core balance 1_600_000_000 duffs, gate 110_000, send `e8cabdecb187e58f74868ed021b37f17f4eed1b0bed63bb9696186f168471f26`, identity `EwATqMdBoCrDQoEBTwcammqAcGcKihzxGrW1qaLoDAJW`, total wall-clock 130s). Framework prerequisites cleared: SPV runtime is live (Task #15 landed) and `BankWallet::send_core_to` is implemented (CR-003 — uses `CoreWallet::send_to_addresses` against the bank's BIP-44 account 0). End-to-end runs are gated on **operator pre-funding the bank's Core (Layer-1) receive address** with at least `100_000 + fee` duffs of testnet DASH. The address is logged at framework init (`platform_wallet::e2e::bank` target, `Bank Core (Layer-1) status core_balance_duffs core_address`); the same address surfaces in the `FrameworkError::Bank` "Bank Core under-funded" message if `send_core_to` is invoked with a zero balance. Tracks the scenario from closed PR `dashpay/rust-dashcore#554` (the parked attempt to ship `BlockchainIdentities*` AccountType variants and flip `WalletAccountCreationOptions::Default` to monitor those addresses) and DET follow-up issue `dash-evo-tool#692`. The wallet's contract today is "identity-auth addresses are NOT monitored"; this case pins that contract so any reshape upstream surfaces here rather than silently in DET or in user funds. - **Wallet feature exercised**: `PlatformWalletInfo::monitored_addresses` (`wallet/platform_wallet_traits.rs:93`) projection for DIP-9 identity-authentication addresses derived via `derive_ecdsa_identity_auth_keypair_from_master` (`wallet/identity/network/identity_handle.rs:143`). Concretely: the `m/9'/coinType'/5'/0'/identity_index'/key_index'` subfeature path, which is not in `WalletAccountCreationOptions::Default` at the pinned `key-wallet` revision. From 1a031123c37ba507feebc3f1b6b8df1e8d9b0c57 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 5 May 2026 17:31:45 +0200 Subject: [PATCH 29/34] =?UTF-8?q?test(rs-platform-wallet/e2e):=20invert=20?= =?UTF-8?q?ID-007=20=E2=80=94=20assert=20correct=20behavior,=20fail=20unti?= =?UTF-8?q?l=20upstream=20fixes=20BlockchainIdentities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ID-007 previously pinned the broken contract — green-while-buggy. That inverts the meaning of the test suite: a green run could mean either "feature works" or "feature still broken in the same way as before", and there's no way to tell at a glance. Flip the polarity. The test now asserts the CORRECT behavior: - identity-auth addresses ARE in `monitored_addresses()` before and after the Layer-1 send, - the wallet's confirmed Core balance INCREASES after the inbound UTXO confirms, - the wallet's UTXO set CONTAINS the new entry. All three assertions currently FAIL because rust-dashcore's `WalletAccountCreationOptions::Default` excludes `BlockchainIdentities*` `AccountType` variants at the pinned `key-wallet` revision (closed PR `dashpay/rust-dashcore#554` attempted this; closed without merge). The negative-axis variant (`identity_index = 1`, unregistered slot) carries the same correct-behavior assertions — registration status is irrelevant to monitoring since the derivation is pure. Renames the test file and function from `..._not_monitored` to `..._monitored` to match the inverted intent. Bumps `wait_for_core_balance` timeout to 5 minutes (testnet block time ~2.5 min plus SPV bloom-filter propagation headroom) since the assertion is now "balance reaches target", not "wait times out". The `#[ignore]` reason now spells out "FAILS by design until upstream lands BlockchainIdentities* support". DET parallel: `dash-evo-tool#692`. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...d_007_identity_auth_addresses_monitored.rs | 258 ++++++++++++++++++ ...7_identity_auth_addresses_not_monitored.rs | 257 ----------------- .../rs-platform-wallet/tests/e2e/cases/mod.rs | 2 +- 3 files changed, 259 insertions(+), 258 deletions(-) create mode 100644 packages/rs-platform-wallet/tests/e2e/cases/id_007_identity_auth_addresses_monitored.rs delete mode 100644 packages/rs-platform-wallet/tests/e2e/cases/id_007_identity_auth_addresses_not_monitored.rs diff --git a/packages/rs-platform-wallet/tests/e2e/cases/id_007_identity_auth_addresses_monitored.rs b/packages/rs-platform-wallet/tests/e2e/cases/id_007_identity_auth_addresses_monitored.rs new file mode 100644 index 0000000000..586245d2a6 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/id_007_identity_auth_addresses_monitored.rs @@ -0,0 +1,258 @@ +//! ID-007 — Identity-auth addresses ARE visible to SPV monitor. +//! +//! Spec: `tests/e2e/TEST_SPEC.md` (### Identity (ID) → ID-007). +//! Pinned status: FAILING — documents an open upstream issue. +//! +//! Asserts the CORRECT behavior: +//! - identity-auth addresses derived via +//! [`derive_ecdsa_identity_auth_keypair_from_master`] ARE in +//! [`WalletInfoInterface::monitored_addresses`]. +//! - Sending Core duffs to one of those addresses INCREASES the +//! wallet's Core balance. +//! - The wallet's UTXO set ends up holding the new UTXO. +//! +//! This test currently FAILS because rust-dashcore's +//! `WalletAccountCreationOptions::Default` does not include the +//! `BlockchainIdentities*` `AccountType` variants (closed PR +//! `dashpay/rust-dashcore#554` attempted this; closed without +//! merge). When upstream lands the fix and exposes those accounts as +//! part of `Default`, this test will start passing — and that's the +//! point: green = feature works, red = feature broken. +//! +//! DET parallel: `dash-evo-tool#692` (the follow-up issue PR +//! `dashpay/rust-dashcore#554` referenced for the DET-side +//! `spv_account_metadata()` match arm). + +use std::time::Duration; + +use dashcore::secp256k1::PublicKey as SecpPublicKey; +use dashcore::{Address, Network, PublicKey}; +use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; +use key_wallet::wallet::root_extended_keys::RootExtendedPrivKey; +use platform_wallet::wallet::identity::network::derive_ecdsa_identity_auth_keypair_from_master; + +use crate::framework::prelude::*; + +/// Funding committed to the registered identity. Modest — the +/// scenario doesn't need a fat identity, only one that exists so the +/// `identity_index = 0` slot is canonically "in use". +const REGISTRATION_FUNDING: u64 = 30_000_000; + +/// Layer-1 send amount targeted at the identity-auth address. ~0.001 +/// DASH; well above the dust threshold so the bank's Core path +/// doesn't reject it on amount alone, well below any per-test budget +/// concern. +const CORE_SEND_DUFFS: u64 = 100_000; + +/// Window for `wait_for_core_balance` to observe the inbound UTXO at +/// confirmed depth. The waiter polls +/// [`TestWallet::core_balance_confirmed`] (see +/// `framework/wait.rs`), which only counts confirmed UTXOs. Testnet +/// block time is ~2.5 minutes; allow generous headroom for one +/// confirmation plus SPV bloom-filter propagation. +const CORE_BALANCE_CONFIRMATION_WINDOW: Duration = Duration::from_secs(300); + +#[ignore = "ID-007 — pins upstream rust-dashcore#554 / blockchain-identities work; \ + currently FAILS by design until WalletAccountCreationOptions::Default \ + includes BlockchainIdentities* AccountType variants. Run with \ + `cargo test -- --ignored` expecting failure. When this test starts \ + passing, the upstream fix has landed."] +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn id_007_identity_auth_addresses_monitored() { + 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: register one identity at slot 0 with modest funding. + // Reuses `setup_with_n_identities` so the canonical identity- + // funding path is exercised; the identity itself isn't load- + // bearing in the assertions, only that slot 0 is "in use". + let s = crate::framework::setup_with_n_identities(1, REGISTRATION_FUNDING) + .await + .expect("setup_with_n_identities failed"); + let identity_zero = s + .identities + .first() + .expect("setup_with_n_identities returned no identities"); + tracing::info!( + target: "platform_wallet::e2e::cases::id_007", + identity_id = %identity_zero.id, + "registered slot-0 identity for ID-007" + ); + + let network = s.base.ctx.config.network; + let seed_bytes = s.base.test_wallet.seed_bytes(); + + // Derive `auth_addr` for (identity_index = 0, key_index = 0) — + // the slot we just registered. Pure derivation; bypasses the + // wallet's `AccountCollection` entirely. P2PKH the resulting + // pubkey to get a Core (Layer-1) address. + let auth_addr_zero = derive_auth_address(&seed_bytes, network, 0, 0) + .expect("derive identity-auth address (identity_index=0, key_index=0)"); + + // Negative-axis variant — same derivation at an UNREGISTERED + // slot. Registration status is irrelevant to monitoring (the + // derivation is pure), so the same correct-behavior assertions + // hold: every (identity_index, key_index) pair under the DIP-9 + // identity-authentication subfeature MUST be monitored. + let auth_addr_one = derive_auth_address(&seed_bytes, network, 1, 0) + .expect("derive identity-auth address (identity_index=1, key_index=0)"); + + // TODO(ID-007): add BLS subfeature variant once + // `derive_*_bls_identity_auth_keypair_from_master` lands in the + // upstream `key-wallet` API. Path: + // `m/9'/coinType'/5'/2'/identity_index'/key_index'`. Same + // correct-behavior assertions apply. + + // Step 3: snapshot `monitored_addresses()` BEFORE any Core send. + // Once upstream lands the fix, both addresses MUST already be in + // the monitored set (the bloom filter regenerates from + // `accounts.all_accounts()` and `BlockchainIdentities*` accounts + // are part of `WalletAccountCreationOptions::Default`). + let monitored_before = s + .base + .test_wallet + .platform_wallet() + .state() + .await + .monitored_addresses(); + assert!( + monitored_before.contains(&auth_addr_zero), + "identity-auth address (slot 0) is NOT in monitored_addresses() \ + before the Core send. Expected the SPV bloom filter to cover \ + every (identity_index, key_index) pair on the DIP-9 \ + identity-authentication subfeature path. This assertion will \ + start passing when upstream rust-dashcore exposes \ + BlockchainIdentities* AccountType variants in \ + WalletAccountCreationOptions::Default \ + (closed PR dashpay/rust-dashcore#554; DET parallel \ + dash-evo-tool#692)." + ); + assert!( + monitored_before.contains(&auth_addr_one), + "identity-auth address (slot 1, unregistered) is NOT in \ + monitored_addresses(). Registration status is irrelevant — \ + the derivation is pure — so every (identity_index, key_index) \ + pair on the DIP-9 identity-authentication subfeature path \ + MUST be monitored. Tracks closed PR dashpay/rust-dashcore#554." + ); + + // Step 4: send `CORE_SEND_DUFFS` from the bank to `auth_addr_zero` + // on Layer-1 via `BankWallet::send_core_to` (CR-003). Returns a + // broadcast `Txid`; we wait below for confirmation via + // `wait_for_core_balance`. + // Use the same lock-free confirmed-balance accessor that + // `wait_for_core_balance` polls — pinning `pre_balance + 1` against + // the same metric the waiter compares against keeps the assertion + // crisp. + let pre_balance = s.base.test_wallet.core_balance_confirmed(); + let _txid = s + .base + .ctx + .bank() + .send_core_to(&auth_addr_zero, CORE_SEND_DUFFS) + .await + .expect("bank.send_core_to (CR-003 prerequisite)"); + + // Step 5: snapshot `monitored_addresses()` AFTER the broadcast. + // The bloom filter is regenerated from `accounts.all_accounts()`; + // identity-auth addresses MUST still appear post-broadcast. + let monitored_after = s + .base + .test_wallet + .platform_wallet() + .state() + .await + .monitored_addresses(); + assert!( + monitored_after.contains(&auth_addr_zero), + "identity-auth address (slot 0) is NOT in monitored_addresses() \ + after the Layer-1 send. Upstream BlockchainIdentities* support \ + is required for the SPV bloom filter to cover this path \ + (rust-dashcore#554)." + ); + assert!( + monitored_after.contains(&auth_addr_one), + "identity-auth address (slot 1, unregistered) is NOT in \ + monitored_addresses() after the Layer-1 send. Registration \ + status is irrelevant; every (identity_index, key_index) pair \ + on the DIP-9 identity-authentication subfeature path must be \ + monitored (rust-dashcore#554)." + ); + + // Step 6: wait UP TO `CORE_BALANCE_CONFIRMATION_WINDOW` for the + // wallet's confirmed Core balance to reflect the inbound UTXO. + // With the upstream fix in place, the SPV bloom filter carries + // `auth_addr_zero` and the inbound UTXO becomes visible once + // confirmed. + let observed = wait_for_core_balance( + &s.base.test_wallet, + pre_balance + 1, + CORE_BALANCE_CONFIRMATION_WINDOW, + ) + .await + .expect( + "wait_for_core_balance timed out waiting for the inbound \ + UTXO at the identity-auth address. Either the SPV bloom \ + filter doesn't carry DIP-9 subfeature 0..3 (the current \ + upstream state — rust-dashcore#554 not merged), or the send \ + didn't confirm within the window. The test asserts the \ + CORRECT contract; failure here documents the open issue.", + ); + tracing::info!( + target: "platform_wallet::e2e::cases::id_007", + observed, + pre_balance, + delta = observed.saturating_sub(pre_balance), + "wallet observed Core balance increase from identity-auth send" + ); + + // Step 7: snapshot the UTXO set and assert it contains the new + // entry to `auth_addr_zero` for `CORE_SEND_DUFFS`. + let utxo_count_to_auth_addr = s + .base + .test_wallet + .platform_wallet() + .state() + .await + .utxos() + .iter() + .filter(|u| u.value() == CORE_SEND_DUFFS && u.address == auth_addr_zero) + .count(); + assert!( + utxo_count_to_auth_addr >= 1, + "wallet's UTXO set does NOT contain a {CORE_SEND_DUFFS}-duff \ + entry to the identity-auth address. The SPV bloom filter \ + needs to carry DIP-9 subfeature 0..3 \ + (rust-dashcore#554)." + ); + + s.teardown().await.expect("teardown"); +} + +/// Derive the P2PKH `dashcore::Address` for the identity-auth keypair +/// at `(identity_index, key_index)` on `network`. Mirrors the +/// derivation in `framework::signer::derive_identity_key` but stops +/// at the public-key → address step instead of building an +/// `IdentityPublicKey`. +fn derive_auth_address( + seed_bytes: &[u8; 64], + network: Network, + identity_index: u32, + key_index: u32, +) -> Result { + let root_priv = RootExtendedPrivKey::new_master(seed_bytes) + .map_err(|err| format!("invalid seed for root xpriv: {err}"))?; + let master = root_priv.to_extended_priv_key(network); + let derived = + derive_ecdsa_identity_auth_keypair_from_master(&master, network, identity_index, key_index) + .map_err(|err| format!("derive ({identity_index}, {key_index}): {err}"))?; + let secp_pubkey = SecpPublicKey::from_slice(&derived.public_key).map_err(|err| { + format!("public_key bytes from derive are not a valid secp256k1 pubkey: {err}") + })?; + Ok(Address::p2pkh(&PublicKey::new(secp_pubkey), network)) +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/id_007_identity_auth_addresses_not_monitored.rs b/packages/rs-platform-wallet/tests/e2e/cases/id_007_identity_auth_addresses_not_monitored.rs deleted file mode 100644 index b974294575..0000000000 --- a/packages/rs-platform-wallet/tests/e2e/cases/id_007_identity_auth_addresses_not_monitored.rs +++ /dev/null @@ -1,257 +0,0 @@ -//! ID-007 — Identity-auth addresses are NOT visible to SPV monitor. -//! -//! Spec: `tests/e2e/TEST_SPEC.md` (### Identity (ID) → ID-007). -//! Pinned status: FRAMEWORK-READY — full test body implemented, -//! `#[ignore]`-tagged. SPV runtime is live (Task #15) and the bank's -//! `send_core_to` helper is wired (CR-003). End-to-end runs need the -//! bank's Core (Layer-1) receive address to be pre-funded on testnet; -//! the address is logged at framework init under target -//! `platform_wallet::e2e::bank`. Tracks closed PR -//! `dashpay/rust-dashcore#554` (the parked attempt to add -//! `BlockchainIdentities*` `AccountType` variants and flip -//! `WalletAccountCreationOptions::Default` to monitor those -//! addresses) and DET follow-up issue `dash-evo-tool#692`. -//! -//! Pins the CURRENT contract: -//! - identity-auth addresses derived via -//! [`derive_ecdsa_identity_auth_keypair_from_master`] are NOT in -//! [`WalletInfoInterface::monitored_addresses`] (because they live -//! on a DIP-9 subfeature path not in -//! `WalletAccountCreationOptions::Default` at the pinned -//! `key-wallet` revision). -//! - Sending Core duffs to one of those addresses does NOT increase -//! the wallet's Core balance (the SPV bloom filter ignores them). -//! - The wallet's UTXO set never observes such a send. -//! -//! When `BlockchainIdentities` support lands upstream and the wallet -//! opts in (any shape — four concrete variants, parameterised -//! subfeature, etc.), FLIP these assertions and the test starts -//! passing for the right reason. The defensive-pin precedent matches -//! `Found-003` / `Found-004`. - -use std::time::Duration; - -use dashcore::secp256k1::PublicKey as SecpPublicKey; -use dashcore::{Address, Network, PublicKey}; -use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; -use key_wallet::wallet::root_extended_keys::RootExtendedPrivKey; -use platform_wallet::wallet::identity::network::derive_ecdsa_identity_auth_keypair_from_master; - -use crate::framework::prelude::*; - -/// Funding committed to the registered identity. Modest — the -/// scenario doesn't need a fat identity, only one that exists so the -/// `identity_index = 0` slot is canonically "in use". -const REGISTRATION_FUNDING: u64 = 30_000_000; - -/// Layer-1 send amount targeted at the identity-auth address. ~0.001 -/// DASH; well above the dust threshold so the bank's would-be Core -/// path doesn't reject it on amount alone, well below any per-test -/// budget concern. -const CORE_SEND_DUFFS: u64 = 100_000; - -/// Negative-window for `wait_for_core_balance`: the test pins that -/// the Core balance does NOT reach `CORE_SEND_DUFFS` even after this -/// long, so the wait is EXPECTED to time out under the current -/// contract. Marvin's spec uses 30 seconds; matched here. -const CORE_BALANCE_NEGATIVE_WINDOW: Duration = Duration::from_secs(30); - -#[ignore = "ID-007 — needs testnet + bank Core (Layer-1) pre-funding. \ - Framework gates cleared: SPV runtime live (Task #15) and \ - BankWallet::send_core_to implemented (CR-003). End-to-end \ - run requires operator-funded bank Core receive address \ - (logged at framework init under platform_wallet::e2e::bank \ - target). Pins the contract that DIP-9 identity-auth \ - addresses are NOT in monitored_addresses(). Tracks closed \ - PR dashpay/rust-dashcore#554."] -#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] -async fn id_007_identity_auth_addresses_not_monitored() { - 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: register one identity at slot 0 with modest funding. - // Reuses `setup_with_n_identities` so the canonical identity- - // funding path is exercised; the identity itself isn't load- - // bearing in the assertions, only that slot 0 is "in use". - let s = crate::framework::setup_with_n_identities(1, REGISTRATION_FUNDING) - .await - .expect("setup_with_n_identities failed"); - let identity_zero = s - .identities - .first() - .expect("setup_with_n_identities returned no identities"); - tracing::info!( - target: "platform_wallet::e2e::cases::id_007", - identity_id = %identity_zero.id, - "registered slot-0 identity for ID-007" - ); - - let network = s.base.ctx.config.network; - let seed_bytes = s.base.test_wallet.seed_bytes(); - - // Derive `auth_addr` for (identity_index = 0, key_index = 0) — - // the slot we just registered. Pure derivation; bypasses the - // wallet's `AccountCollection` entirely. P2PKH the resulting - // pubkey to get a Core (Layer-1) address. - let auth_addr_zero = derive_auth_address(&seed_bytes, network, 0, 0) - .expect("derive identity-auth address (identity_index=0, key_index=0)"); - - // Negative variant — same derivation at an UNREGISTERED slot. - // Registration status is irrelevant to monitoring (the - // derivation is pure), so the same three current-contract - // assertions hold. - let auth_addr_one = derive_auth_address(&seed_bytes, network, 1, 0) - .expect("derive identity-auth address (identity_index=1, key_index=0)"); - - // TODO(ID-007): add BLS subfeature negative variant once - // `derive_*_bls_identity_auth_keypair_from_master` lands in the - // upstream `key-wallet` API. Path: - // `m/9'/coinType'/5'/2'/identity_index'/key_index'`. Same three - // current-contract assertions are expected to hold. - - // Step 3: snapshot `monitored_addresses()` BEFORE any Core send. - // The wallet has been live since `setup_with_n_identities` - // returned, so this is the steady-state monitored set. - let monitored_before = s - .base - .test_wallet - .platform_wallet() - .state() - .await - .monitored_addresses(); - assert!( - !monitored_before.contains(&auth_addr_zero), - "PRE-pin violated: identity-auth address (slot 0) already in \ - monitored_addresses(). The current contract at the pinned \ - key-wallet revision excludes DIP-9 subfeature 0..3 from \ - WalletAccountCreationOptions::Default; if this fires, \ - upstream has flipped the contract and this test must flip \ - its assertions in the same PR." - ); - assert!( - !monitored_before.contains(&auth_addr_one), - "PRE-pin violated: identity-auth address (slot 1, unregistered) \ - already in monitored_addresses(). Registration status is \ - irrelevant — the derivation is pure — so the same contract \ - applies to every (identity_index, key_index) pair." - ); - - // Step 4: send `CORE_SEND_DUFFS` from the bank to `auth_addr_zero` - // on Layer-1 via `BankWallet::send_core_to` (CR-003). Returns a - // broadcast `Txid`; we don't wait for instant-lock because the - // negative contract is "the wallet's monitored set never sees - // this". The `wait_for_core_balance` call below is what bounds - // observation of the (expected absent) UTXO. - // Use the same lock-free confirmed-balance accessor that - // `wait_for_core_balance` polls — pinning `pre_balance + 1` against - // the same metric the waiter compares against keeps the negative - // contract crisp (the timeout fires because `auth_addr_zero` isn't - // in `monitored_addresses()`, not because the two readings drift). - let pre_balance = s.base.test_wallet.core_balance_confirmed(); - let _txid = s - .base - .ctx - .bank() - .send_core_to(&auth_addr_zero, CORE_SEND_DUFFS) - .await - .expect("bank.send_core_to (CR-003 prerequisite — currently unimplemented!)"); - - // Step 5: snapshot `monitored_addresses()` AFTER the broadcast. - // The bloom filter regenerates from `accounts.all_accounts()`, - // which still excludes the BlockchainIdentities subfeature, so - // the set must be unchanged with respect to `auth_addr_*`. - let monitored_after = s - .base - .test_wallet - .platform_wallet() - .state() - .await - .monitored_addresses(); - assert!( - !monitored_after.contains(&auth_addr_zero), - "POST-pin violated (slot 0): identity-auth address appeared in \ - monitored_addresses() after a Layer-1 send. Upstream has \ - silently begun monitoring DIP-9 subfeature 0..3; flip the \ - assertions in the same PR that wires the change." - ); - assert!( - !monitored_after.contains(&auth_addr_one), - "POST-pin violated (slot 1): identity-auth address for an \ - unregistered slot appeared in monitored_addresses() after a \ - Layer-1 send. The send didn't even target this slot — \ - something has flipped the default monitored set." - ); - - // Step 6: wait UP TO `CORE_BALANCE_NEGATIVE_WINDOW` for the Core - // balance to reflect the inbound UTXO. Per the current contract - // it MUST NOT — the SPV bloom filter doesn't carry `auth_addr_zero`, - // so the UTXO is invisible to the wallet. We pin the timeout as - // EXPECTED. - let core_wait = wait_for_core_balance( - &s.base.test_wallet, - pre_balance + 1, - CORE_BALANCE_NEGATIVE_WINDOW, - ) - .await; - assert!( - core_wait.is_err(), - "POST-pin violated: wallet observed a Core balance increase \ - after sending to an identity-auth address. Either upstream \ - flipped the monitored-set contract, or the SPV path now \ - reaches into DIP-9 subfeature 0..3 by some other route. \ - Either way, ID-007 must flip its assertions in the same PR. \ - (observed value: {:?})", - core_wait.ok() - ); - - // Step 7: snapshot the UTXO set and assert it does not contain - // a `CORE_SEND_DUFFS`-valued entry to `auth_addr_zero`. - let utxo_count_to_auth_addr = s - .base - .test_wallet - .platform_wallet() - .state() - .await - .utxos() - .iter() - .filter(|u| u.value() == CORE_SEND_DUFFS && u.address == auth_addr_zero) - .count(); - assert_eq!( - utxo_count_to_auth_addr, 0, - "POST-pin violated: the wallet's UTXO set contains a \ - {CORE_SEND_DUFFS}-duff entry to the identity-auth address. \ - The SPV bloom filter must have started carrying DIP-9 \ - subfeature 0..3 — flip the assertions and document the new \ - contract." - ); - - s.teardown().await.expect("teardown"); -} - -/// Derive the P2PKH `dashcore::Address` for the identity-auth keypair -/// at `(identity_index, key_index)` on `network`. Mirrors the -/// derivation in `framework::signer::derive_identity_key` but stops -/// at the public-key → address step instead of building an -/// `IdentityPublicKey`. -fn derive_auth_address( - seed_bytes: &[u8; 64], - network: Network, - identity_index: u32, - key_index: u32, -) -> Result { - let root_priv = RootExtendedPrivKey::new_master(seed_bytes) - .map_err(|err| format!("invalid seed for root xpriv: {err}"))?; - let master = root_priv.to_extended_priv_key(network); - let derived = - derive_ecdsa_identity_auth_keypair_from_master(&master, network, identity_index, key_index) - .map_err(|err| format!("derive ({identity_index}, {key_index}): {err}"))?; - let secp_pubkey = SecpPublicKey::from_slice(&derived.public_key).map_err(|err| { - format!("public_key bytes from derive are not a valid secp256k1 pubkey: {err}") - })?; - Ok(Address::p2pkh(&PublicKey::new(secp_pubkey), network)) -} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/mod.rs b/packages/rs-platform-wallet/tests/e2e/cases/mod.rs index e316c2a97e..d87abd95a9 100644 --- a/packages/rs-platform-wallet/tests/e2e/cases/mod.rs +++ b/packages/rs-platform-wallet/tests/e2e/cases/mod.rs @@ -12,7 +12,7 @@ pub mod id_001_register_identity_from_addresses; pub mod id_002_top_up_identity; pub mod id_003_identity_to_identity_transfer; pub mod id_005_identity_to_addresses_transfer; -pub mod id_007_identity_auth_addresses_not_monitored; +pub mod id_007_identity_auth_addresses_monitored; pub mod id_sweep_recovers_identity_credits; pub mod pa_001_multi_output; pub mod pa_001b_change_address_branch; From d3684363773468119dd2cb59892463129f5f3089 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 5 May 2026 17:31:55 +0200 Subject: [PATCH 30/34] docs(rs-platform-wallet/e2e): update ID-007 spec status to FAILING-by-design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ID-007's status flipped from "Pass" (which pinned the broken contract as-is) to "FAILING — by design until upstream lands `BlockchainIdentities*` support". The Quick index entry, the per-entry Status, the Assertions block, the Variants section, the Rationale and the Notes are all rewritten to reflect: the test asserts the CORRECT contract; green = feature works, red = feature broken; contrast with `Found-003` / `Found-004` (defensive pins of broken behavior, kept where the bug is the contract). No `red` / `green` legend exists in TEST_SPEC.md to update — status values are free-form English (Pass / IMPLEMENTED / BLOCKED / STUB / FAILING). Quick index has no Status column. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rs-platform-wallet/tests/e2e/TEST_SPEC.md | 43 ++++++++++++------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md b/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md index ae6113ed26..7b5f2d8077 100644 --- a/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md +++ b/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md @@ -149,7 +149,7 @@ Source citations for the "Wallet API exists" column are listed inline per case | ID-003b | Concurrent identity-to-identity transfers serialise on identity nonce | P2 | M | | ID-005b | `transfer_credits_to_addresses` with empty outputs | P2 | S | | ID-006b | Identity-key derivation index boundary (`0` and `DEFAULT_GAP_LIMIT - 1`) | P2 | M | -| ID-007 | Identity-auth addresses are visible to SPV monitor (BLOCKED on Task #15) | P2 | M | +| ID-007 | Identity-auth addresses are visible to SPV monitor (FAILING — pins upstream fix) | P2 | M | | TK-001 | Token transfer between two identities | P1 | L | | TK-001b | Token transfer of amount 0 | P2 | S | | TK-001c | Token transfer across re-issued identity (signer rotation) | P2 | M | @@ -913,7 +913,20 @@ Counts by priority: **P0: 10**, **P1: 24** (incl. 2 post-Task #15), **P2: 57** ( #### ID-007 — Identity-auth addresses are visible to SPV monitor - **Priority**: P2 -- **Status**: Pass — full test body implemented at `tests/e2e/cases/id_007_identity_auth_addresses_not_monitored.rs`; `#[ignore]`-tagged (testnet, gated on `PLATFORM_WALLET_E2E_BANK_CORE_GATE`). Verified PASS on testnet at HEAD `32ee2cd6be` (mn-list 8.5s warm cache, bank Core balance 1_600_000_000 duffs, gate 110_000, send `e8cabdecb187e58f74868ed021b37f17f4eed1b0bed63bb9696186f168471f26`, identity `EwATqMdBoCrDQoEBTwcammqAcGcKihzxGrW1qaLoDAJW`, total wall-clock 130s). Framework prerequisites cleared: SPV runtime is live (Task #15 landed) and `BankWallet::send_core_to` is implemented (CR-003 — uses `CoreWallet::send_to_addresses` against the bank's BIP-44 account 0). End-to-end runs are gated on **operator pre-funding the bank's Core (Layer-1) receive address** with at least `100_000 + fee` duffs of testnet DASH. The address is logged at framework init (`platform_wallet::e2e::bank` target, `Bank Core (Layer-1) status core_balance_duffs core_address`); the same address surfaces in the `FrameworkError::Bank` "Bank Core under-funded" message if `send_core_to` is invoked with a zero balance. Tracks the scenario from closed PR `dashpay/rust-dashcore#554` (the parked attempt to ship `BlockchainIdentities*` AccountType variants and flip `WalletAccountCreationOptions::Default` to monitor those addresses) and DET follow-up issue `dash-evo-tool#692`. The wallet's contract today is "identity-auth addresses are NOT monitored"; this case pins that contract so any reshape upstream surfaces here rather than silently in DET or in user funds. +- **Status**: FAILING — by design until upstream lands BlockchainIdentities* + support. The test asserts the CORRECT behavior (identity-auth addresses + ARE monitored, Core balance DOES increase, UTXO set holds the new entry). + Will start passing when rust-dashcore's `WalletAccountCreationOptions::Default` + exposes the identity-authentication subfeature paths. Tracks closed PR + `dashpay/rust-dashcore#554` / DET issue `dash-evo-tool#692`. Test body lives + at `tests/e2e/cases/id_007_identity_auth_addresses_monitored.rs`; + `#[ignore]`-tagged so a default `cargo test` stays green. End-to-end runs + (`cargo test -- --ignored`) currently FAIL by design — green = feature + works, red = feature broken. Framework prerequisites are cleared (SPV + runtime live, `BankWallet::send_core_to` implemented), and runs are + gated on **operator pre-funding the bank's Core (Layer-1) receive address** + with at least `100_000 + fee` duffs of testnet DASH (the address is logged + at framework init under target `platform_wallet::e2e::bank`). - **Wallet feature exercised**: `PlatformWalletInfo::monitored_addresses` (`wallet/platform_wallet_traits.rs:93`) projection for DIP-9 identity-authentication addresses derived via `derive_ecdsa_identity_auth_keypair_from_master` (`wallet/identity/network/identity_handle.rs:143`). Concretely: the `m/9'/coinType'/5'/0'/identity_index'/key_index'` subfeature path, which is not in `WalletAccountCreationOptions::Default` at the pinned `key-wallet` revision. - **DET parallel**: `dash-evo-tool#692` (the follow-up issue PR `dashpay/rust-dashcore#554` referenced for the DET-side `spv_account_metadata()` match arm). - **Preconditions**: @@ -927,14 +940,14 @@ Counts by priority: **P0: 10**, **P1: 24** (incl. 2 post-Task #15), **P2: 57** ( 4. Send `100_000` duffs from the Core-funded bank to `auth_addr` on Layer-1; wait for instant-lock. 5. Snapshot `wallet.monitored_addresses()` *after* the broadcast. 6. Wait up to `30s` for the wallet's Core balance to reflect the incoming UTXO; record whether it does. -- **Assertions** (pin the **current** contract, not the aspirational one — flip to the aspirational shape only after the upstream decision lands and the relevant DET issue is closed): - - `auth_addr` is **NOT** in `monitored_addresses()` either before or after step 4 (current contract). - - The wallet's Core balance does **NOT** increase after step 6 within the timeout (current contract). - - The wallet's UTXO set does **NOT** contain the new `100_000`-duff UTXO (current contract). - - When the eventual `BlockchainIdentities` support lands upstream and the wallet opts in, **flip** all three assertions and the test starts passing for the right reason. -- **Negative variants** (covered inline in the same test — registration status is irrelevant, the derivation is pure): - - Compute `auth_addr` for `identity_index = 1` (an unregistered slot) — same three current-contract assertions hold. - - Repeat for the BLS subfeature path (`m/9'/coinType'/5'/2'/identity_index'/key_index'`) once `derive_*_bls_identity_auth_keypair_from_master` lands; assert the same negative. (Deferred — TODO comment in the test body.) +- **Assertions** (pin the **correct** contract — green when the feature works, red while upstream remains unfixed): + - `auth_addr` **IS** in `monitored_addresses()` both before and after step 4. + - The wallet's Core balance **DOES** increase to at least `pre_balance + 1` within the confirmation window after step 6. + - The wallet's UTXO set **DOES** contain the new `100_000`-duff UTXO at `auth_addr`. + - All three currently fail because `WalletAccountCreationOptions::Default` excludes `BlockchainIdentities*` accounts at the pinned `key-wallet` revision; the test starts passing when upstream lands the fix. +- **Variants** (covered inline in the same test — registration status is irrelevant, the derivation is pure): + - Compute `auth_addr` for `identity_index = 1` (an unregistered slot) — same correct-behavior assertions hold (the address must be monitored regardless of registration state). + - Repeat for the BLS subfeature path (`m/9'/coinType'/5'/2'/identity_index'/key_index'`) once `derive_*_bls_identity_auth_keypair_from_master` lands; assert the same correct behavior. (Deferred — TODO comment in the test body.) - **Harness extensions required**: - SPV runtime re-enabled (Task #15 — same prerequisite as `CR-001`). - Core-funded bank wallet helper (same prerequisite as `CR-003`). Stubbed for now via `Bank::send_core_to(..) -> unimplemented!()`; wire through when CR-003 helpers land. @@ -942,13 +955,13 @@ Counts by priority: **P0: 10**, **P1: 24** (incl. 2 post-Task #15), **P2: 57** ( - Wave A's `SeedBackedIdentitySigner` (already needed for `ID-001`). - **Estimated complexity**: M (test body is short — most of the cost is the prerequisite SPV + Core-faucet bring-up that `CR-001` and `CR-003` already require). - **Funding budget**: `100_000` Core duffs (~0.001 DASH) per run for the Layer-1 send; rounding for Core-tx fee. Negligible compared to the credit budget of any P0/P1 case. -- **Rationale**: Pins the wallet's contract for "which DIP-9 subfeatures get monitored?" The closed PR `dashpay/rust-dashcore#554` user story explicitly called out identity-auth addresses as a scenario it wanted SPV-monitored; the PR is closed without merge or supersede pointer, and the current contract in the pinned `key-wallet` rev silently excludes them. ID-007 makes that exclusion an asserted contract so that: - 1. anyone who flips `WalletAccountCreationOptions::Default` to include `BlockchainIdentities*` accounts (or any equivalent reshape upstream) breaks this test loudly, and the assertion bodies can be flipped in the same PR; - 2. nobody on the platform side accidentally relies on the monitored-addresses set covering identity-auth addresses before the upstream story lands. +- **Rationale**: Pins the **correct** contract for "which DIP-9 subfeatures get monitored?" The closed PR `dashpay/rust-dashcore#554` user story explicitly called out identity-auth addresses as a scenario it wanted SPV-monitored; the PR is closed without merge or supersede pointer, and the current contract in the pinned `key-wallet` rev silently excludes them. ID-007 inverts the polarity of the previous defensive-pin: instead of asserting the broken behavior holds (green while the bug exists, misleading), the test asserts the correct behavior and FAILS today. That way: + 1. anyone who flips `WalletAccountCreationOptions::Default` to include `BlockchainIdentities*` accounts (or any equivalent reshape upstream) sees this test go green, signalling the feature is fixed; + 2. nobody on the platform side mistakes a green ID-007 for "the feature works" while it doesn't — broken feature stays red. - **Operator notes**: First cold-cache run takes ~15 minutes because SPV walks compact filters from genesis (~1.47M testnet blocks). Subsequent runs reuse the on-disk cache and complete in seconds. The harness gates init on `PLATFORM_WALLET_E2E_BANK_CORE_GATE` (default `0` — skip); set it to at least `110_000` (`100_000` send + `~10_000` fee reserve) before invoking ID-007 so the bank's `core_balance_confirmed` reflects the post-scan total instead of a false-zero mid-scan. Set `RUST_LOG=info,platform_wallet::e2e::wait=info` to see scan-progress lines (`scan_height` vs `scan_tip`) every 30s. - **Notes**: - - Today `derive_ecdsa_identity_auth_keypair_from_master` is the only DIP-9 subfeature `rs-platform-wallet` exposes (subfeature 0, ECDSA). Adding the BLS / Hash160 negative variants is contingent on the upstream `key-wallet` API gaining BLS derivation helpers. - - This is a **defensive contract pin**, not a feature test. Same shape as `Found-003` / `Found-004` — pin a known-incomplete behaviour as the contract until someone explicitly extends it. + - Today `derive_ecdsa_identity_auth_keypair_from_master` is the only DIP-9 subfeature `rs-platform-wallet` exposes (subfeature 0, ECDSA). Adding the BLS / Hash160 variants is contingent on the upstream `key-wallet` API gaining BLS derivation helpers. + - This is a **failing-by-design feature test**: it asserts the correct end-state and stays red until upstream lands the fix. Contrast with `Found-003` / `Found-004` (defensive pins of broken behavior, green-while-broken — kept where the bug is the contract). ID-007 inverts that polarity because identity-auth monitoring is a feature people will eventually depend on; pretending it works (green) would be misleading. ### Tokens (TK) From 69ff154bad5a21f773df67d89db354521eb4f886 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 5 May 2026 17:33:55 +0200 Subject: [PATCH 31/34] fix(rs-platform-wallet/e2e): CR-003 POST-pin uses credits, not duffs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Identity balances are denominated in credits (`dpp::fee::Credits`); the asset-lock amount is in duffs. The previous POST-pin compared the two without conversion, so a successful registration that landed ~99.9M duffs (≈ 99.9G credits, fees subtracted) tripped the upper bound `observed <= ASSET_LOCK_AMOUNT (100M)` by 1000×. Convert via `dpp::balances::credits::CREDITS_PER_DUFF` (= 1000) so both sides of every comparison are in credits, and update the panic message to call out the unit explicitly. Marvin's QA-001 (HIGH). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../cr_003_asset_lock_funded_registration.rs | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/rs-platform-wallet/tests/e2e/cases/cr_003_asset_lock_funded_registration.rs b/packages/rs-platform-wallet/tests/e2e/cases/cr_003_asset_lock_funded_registration.rs index 0ec5f5d2d8..ea4e700031 100644 --- a/packages/rs-platform-wallet/tests/e2e/cases/cr_003_asset_lock_funded_registration.rs +++ b/packages/rs-platform-wallet/tests/e2e/cases/cr_003_asset_lock_funded_registration.rs @@ -33,6 +33,7 @@ use std::collections::BTreeMap; use std::time::Duration; use dash_sdk::platform::Fetch; +use dpp::balances::credits::CREDITS_PER_DUFF; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::{KeyID, Purpose, SecurityLevel}; use dpp::prelude::Identity; @@ -193,20 +194,27 @@ async fn cr_003_asset_lock_funded_registration() { // threshold is a deterministic, fee-tolerant lower bound — testnet // chain-time fees are well below `ASSET_LOCK_AMOUNT / 2`, so this // round-trips even across protocol-version fee bumps without - // pinning a brittle exact number. - let observed_balance = wait_for_identity_balance( + // pinning a brittle exact number. Identity balances are denominated + // in credits (`dpp::fee::Credits`), the asset-lock amount in duffs; + // the per-duff conversion factor is `CREDITS_PER_DUFF` (= 1000) per + // dpp's `balances::credits` module. + let expected_credits_min = ASSET_LOCK_AMOUNT.saturating_mul(CREDITS_PER_DUFF) / 2; + let expected_credits_max = ASSET_LOCK_AMOUNT.saturating_mul(CREDITS_PER_DUFF); + let observed_credits = wait_for_identity_balance( s.test_wallet.platform_wallet().sdk(), identity_id, - ASSET_LOCK_AMOUNT / 2, + expected_credits_min, IDENTITY_VISIBILITY_TIMEOUT, ) .await - .expect("identity balance reached half-lock threshold"); + .expect("identity balance (credits) reached half-lock threshold"); assert!( - observed_balance <= ASSET_LOCK_AMOUNT, - "POST-pin violated: observed identity balance {observed_balance} > \ - ASSET_LOCK_AMOUNT {ASSET_LOCK_AMOUNT}. Registration cannot credit more \ - than the asset-lock output value (fees are subtracted, not added)." + observed_credits <= expected_credits_max, + "POST-pin violated: observed identity balance {observed_credits} credits \ + > full asset-lock {expected_credits_max} credits \ + (= ASSET_LOCK_AMOUNT {ASSET_LOCK_AMOUNT} duffs * CREDITS_PER_DUFF \ + {CREDITS_PER_DUFF}). Registration cannot credit more than the \ + asset-lock output value (fees are subtracted, not added)." ); // Step 5: round-trip the identity via the SDK to assert the From fa55e64aeb0a5b1ffe623c872fcdc8771f841fd1 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 5 May 2026 17:34:25 +0200 Subject: [PATCH 32/34] chore(rs-platform-wallet/e2e): wait_for_core_balance logs which path satisfied target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marvin's QA-002 (LOW) flagged that the helper returned in 2.002s for CR-003 — well below testnet block time. Investigation against the pinned `key-wallet` rev (`fe2476611fcf72d6f36f1154a39a2f9af3b6a248`) confirmed `WalletCoreBalance::confirmed` counts mature UTXOs that are EITHER in a block OR InstantSend-locked (per upstream rustdoc on `balance.rs:18`). There is no separate accessor that excludes IS-locked UTXOs at this revision; tightening the helper to require strictly block-confirmed semantics would require an upstream API change. Document the actual semantics honestly in the rustdoc (the previous "deliberately NOT counted" claim about IS-locked UTXOs was wrong), and add a `path` field to the success-log line distinguishing `pre_funded_workdir_cache` (target met on first poll → likely a pre-existing UTXO, not freshly arriving funds) from `confirmed_or_is_locked` (at least one poll observed below-target before the threshold was reached). This lets future post-mortems on suspiciously fast returns tell the two paths apart at a glance without inferring it from elapsed timestamps. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tests/e2e/framework/wait.rs | 52 +++++++++++++++---- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/packages/rs-platform-wallet/tests/e2e/framework/wait.rs b/packages/rs-platform-wallet/tests/e2e/framework/wait.rs index 4947c22e92..49268f7913 100644 --- a/packages/rs-platform-wallet/tests/e2e/framework/wait.rs +++ b/packages/rs-platform-wallet/tests/e2e/framework/wait.rs @@ -126,22 +126,40 @@ pub async fn wait_for_balance( } } -/// Wait for the wallet's Layer-1 Core *confirmed* balance (in duffs) +/// Wait for the wallet's Layer-1 Core "confirmed" balance (in duffs) /// to reach at least `expected_min`. /// /// Polls [`TestWallet::core_balance_confirmed`] — the lock-free atomic /// fed by the SPV path's `WalletBalance::confirmed` — every -/// [`BACKSTOP_WAKE_INTERVAL`] until the threshold is met. Mempool / -/// instant-locked-but-unconfirmed UTXOs are deliberately NOT counted: -/// downstream callers (asset-lock construction in CR-003 onwards) need -/// confirmed UTXOs to reference, and a mempool-eager return would let -/// `setup_with_core_funded_test_wallet` hand back a wallet whose -/// `core_balance_confirmed()` is still 0. The SPV bloom-filter feed -/// updates the atomic asynchronously, so a poll-based approach is -/// sufficient — there's no `Notified` future on the Core side -/// analogous to [`wait_for_balance`]'s wait hub. Returns +/// [`BACKSTOP_WAKE_INTERVAL`] until the threshold is met. +/// +/// **Caveat on "confirmed":** at the pinned `key-wallet` revision, +/// `WalletCoreBalance::confirmed` counts mature UTXOs that are *either* +/// in a block *or* InstantSend-locked (per the upstream rustdoc). It +/// excludes pure-mempool UTXOs (those land in `unconfirmed`), but it +/// does NOT distinguish IS-locked-but-unconfirmed from +/// block-confirmed. Mempool-eager returns are still avoided — that's +/// enough to gate `setup_with_core_funded_test_wallet` on a +/// proof-strength UTXO usable for asset-lock construction (CR-003 +). +/// If a future test needs a strictly block-confirmed UTXO (e.g. +/// confirmation-count assertions), that will require either an +/// upstream API change or a sibling helper that consults raw UTXO +/// metadata directly. The SPV feed updates the atomic asynchronously, +/// so polling is sufficient — there's no `Notified` future on the +/// Core side analogous to [`wait_for_balance`]'s wait hub. Returns /// [`FrameworkError::Cleanup`] on `timeout`. /// +/// On success the success-log line includes a `path` field naming the +/// branch that satisfied the threshold: +/// - `confirmed_or_is_locked` — the confirmed atomic reached the +/// target after at least one poll observed it below. Cannot +/// distinguish in-block vs IS-lock at this layer; see caveat above. +/// - `pre_funded_workdir_cache` — the threshold was already met on the +/// very first poll, before any new SPV activity. Indicates a +/// pre-existing UTXO from a prior run's persisted workdir; if the +/// test relies on a *fresh* funding event this is a false-positive +/// signal and the caller should consider clearing the workdir. +/// /// Used by [`super::setup_with_core_funded_test_wallet`] (positive /// arrival on the test wallet's BIP-44 account 0) and by `ID-007` /// (negative pin: identity-auth addresses are NOT in @@ -154,19 +172,33 @@ pub async fn wait_for_core_balance( ) -> FrameworkResult { let start = Instant::now(); let deadline = Instant::now() + timeout; + let mut polls = 0u64; loop { let observed = test_wallet.core_balance_confirmed(); if observed >= expected_min { + // First-poll success means the threshold was already met + // before this helper saw any new event — pre-funded + // workdir cache, not freshly arriving funds. Surface the + // distinction so post-mortems on suspiciously fast returns + // (Marvin's QA-002 on CR-003) can tell the two paths apart + // at a glance. + let path = if polls == 0 { + "pre_funded_workdir_cache" + } else { + "confirmed_or_is_locked" + }; tracing::info!( target: "platform_wallet::e2e::wait", observed, expected_min, elapsed = ?start.elapsed(), + path, "core balance reached target" ); return Ok(observed); } + polls += 1; tracing::debug!( target: "platform_wallet::e2e::wait", observed, From dc186fde1ae29923088e2a7857f91973497105f8 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 5 May 2026 17:40:32 +0200 Subject: [PATCH 33/34] docs(rs-platform-wallet/e2e): flip CR-003 to Pass after units fix verified Asset-lock-funded identity registration runs end-to-end against testnet: asset-lock built, IS-lock observed, identity registered on-chain, balance decrement asserted in duffs (post units fix). Test gated on PLATFORM_WALLET_E2E_BANK_CORE_GATE. Verified at HEAD fa55e64aeb. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md b/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md index 7b5f2d8077..2d12cd4fe2 100644 --- a/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md +++ b/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md @@ -169,7 +169,7 @@ Source citations for the "Wallet API exists" column are listed inline per case | TK-014 | Group-action gateway: queue a mint, list pending, co-sign | P2 | L | | CR-001 | SPV mn-list sync readiness | P1 | M | | CR-002 | Core wallet receive address derivation | P1 | M | -| CR-003 | Asset-lock-funded identity registration (full path) (STUB — needs bank Core funding) | P2 | L | +| CR-003 | Asset-lock-funded identity registration (full path) | P2 | L | | CT-001 | Document put: deploy a fixture data contract | P1 | M | | CT-002 | Document put / replace lifecycle | P2 | M | | CT-003 | Contract update (add document type) | P2 | M | @@ -1382,7 +1382,7 @@ so that when SPV lands, the test bodies can be written without further design. #### CR-003 — Asset-lock-funded identity registration (full path) - **Priority**: P2 (post-Task #15) -- **Status**: STUB — full test body implemented at `tests/e2e/cases/cr_003_asset_lock_funded_registration.rs`, `#[ignore]`-tagged. Framework prerequisites cleared: SPV runtime live (Task #15), `BankWallet::send_core_to` wired (ID-007 / CR-003), and the new `framework::setup_with_core_funded_test_wallet(duffs)` helper lands `TEST_WALLET_CORE_FUNDING` duffs on the test wallet's BIP-44 account 0 before the asset-lock build. End-to-end runs are gated on the bank's Core (Layer-1) primary receive address holding at least `TEST_WALLET_CORE_FUNDING + CORE_TX_FEE_RESERVE` (≈ 200_010_000 duffs ≈ 2.0001 DASH testnet); under-funded surfaces as `FrameworkError::Bank` with the bank's Core address embedded so the operator-actionable "top up at <addr>" message reaches the test log unchanged. The bank Core address is logged once per process at framework init under the `platform_wallet::e2e::bank` target. +- **Status**: Pass — `tests/e2e/cases/cr_003_asset_lock_funded_registration.rs` (`#[ignore]`-tagged; runs gated on `PLATFORM_WALLET_E2E_BANK_CORE_GATE`). Builds the asset-lock tx via `setup_with_core_funded_test_wallet(TEST_WALLET_CORE_FUNDING)`, waits for the IS-lock, registers the identity, and pins on-chain identity existence + `tracked_asset_locks` recording + Core-balance decrement (lock amount + fee, in duffs). End-to-end runs are gated on the bank's Core (Layer-1) primary receive address holding at least `TEST_WALLET_CORE_FUNDING + CORE_TX_FEE_RESERVE` (≈ 200_010_000 duffs ≈ 2.0001 DASH testnet); under-funded surfaces as `FrameworkError::Bank` with the bank's Core address embedded so the operator-actionable "top up at <addr>" message reaches the test log unchanged. The bank Core address is logged once per process at framework init under the `platform_wallet::e2e::bank` target. - **Wallet feature exercised**: `wallet/asset_lock/build.rs:39` (`build_asset_lock_transaction`) + `wallet/asset_lock/build.rs:285` (`create_funded_asset_lock_proof`) + `wallet/identity/network/registration.rs:59` (`register_identity_with_funding_external_signer` driving `IdentityFundingMethod::FundWithWallet`). - **DET parallel**: `dash-evo-tool/tests/backend-e2e/core_tasks.rs:132` (`test_tc004_create_registration_asset_lock`). - **Preconditions**: CR-001 + a Core-funded test wallet (operator funds via testnet faucet). From aace4d90874ccb6c748f915478d64d1e5c27c6fc Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 5 May 2026 19:03:18 +0200 Subject: [PATCH 34/34] =?UTF-8?q?test(rs-platform-wallet/e2e):=20restore?= =?UTF-8?q?=20ID-007=20=E2=80=94=20pin=20intentional=20not-monitored=20con?= =?UTF-8?q?tract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ID-007 reverts the prior assertion-flip and once again pins the **intentional** architecture: DIP-9 identity-authentication subfeature addresses (subfeature 0..3, 6-component path `m/9'/coinType'/5'/{0,1,2,3}'/identity_index'/key_index'`) are NOT in `PlatformWalletInfo::monitored_addresses()`, sending Core duffs to one does NOT increase the wallet's Core balance, and the UTXO set never observes such a send. Investigation rationale (documented in the file docstring and the TEST_SPEC.md rationale block): dash-evo-tool — the canonical Platform client — treats these addresses as pure key material; `account_summary.rs:226-229` explicitly notes they "usually hold zero balance"; `receive_address()` returns BIP-44 paths only; the UI hides them outside developer-mode "Identity System" view. No standard flow sends Layer-1 Dash to identity-auth addresses. The closed PR `dashpay/rust-dashcore#554` was speculative future-proofing for a hypothetical use case, not a fix for an active bug — its rejection was correct. Restoration shape: - File renamed back from `id_007_identity_auth_addresses_monitored.rs` to `id_007_identity_auth_addresses_not_monitored.rs`; `cases/mod.rs` follows. - Test fn renamed to `id_007_identity_auth_addresses_not_monitored`. - `monitored_before` / `monitored_after` flipped from `contains` to `!contains` for both `auth_addr_zero` and `auth_addr_one`. - `wait_for_core_balance` window restored to `CORE_BALANCE_NEGATIVE_WINDOW = 30s` and the call is asserted `.is_err()` (timeout EXPECTED). - UTXO assertion flipped to `assert_eq!(utxo_count_to_auth_addr, 0)`. - File docstring and `#[ignore]` reason rewritten to frame the test as a defensive pin of intentional behavior — green = architecture intact, red = regression to investigate before flipping. - TEST_SPEC.md ID-007 entry rewritten in the same Pass/intentional framing; quick-index row updated; assertions/scenario/rationale blocks aligned. Verification: - `cargo fmt --all` — clean. - `cargo check --tests --all-features -p platform-wallet` — clean. - `cargo clippy --tests --all-features -p platform-wallet -- -D warnings` — clean. - `cargo test --lib -p platform-wallet` — 141 passed, 0 failed. End-to-end (`cargo test -- --ignored` on ID-007) is expected to PASS; deferred to operator/Marvin verification — testnet not run from this agent. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rs-platform-wallet/tests/e2e/TEST_SPEC.md | 68 ++--- ...d_007_identity_auth_addresses_monitored.rs | 258 ----------------- ...7_identity_auth_addresses_not_monitored.rs | 263 ++++++++++++++++++ .../rs-platform-wallet/tests/e2e/cases/mod.rs | 2 +- 4 files changed, 298 insertions(+), 293 deletions(-) delete mode 100644 packages/rs-platform-wallet/tests/e2e/cases/id_007_identity_auth_addresses_monitored.rs create mode 100644 packages/rs-platform-wallet/tests/e2e/cases/id_007_identity_auth_addresses_not_monitored.rs diff --git a/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md b/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md index 2d12cd4fe2..01ad8a0349 100644 --- a/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md +++ b/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md @@ -149,7 +149,7 @@ Source citations for the "Wallet API exists" column are listed inline per case | ID-003b | Concurrent identity-to-identity transfers serialise on identity nonce | P2 | M | | ID-005b | `transfer_credits_to_addresses` with empty outputs | P2 | S | | ID-006b | Identity-key derivation index boundary (`0` and `DEFAULT_GAP_LIMIT - 1`) | P2 | M | -| ID-007 | Identity-auth addresses are visible to SPV monitor (FAILING — pins upstream fix) | P2 | M | +| ID-007 | Identity-auth addresses are intentionally NOT monitored (pins intended architecture) | P2 | M | | TK-001 | Token transfer between two identities | P1 | L | | TK-001b | Token transfer of amount 0 | P2 | S | | TK-001c | Token transfer across re-issued identity (signer rotation) | P2 | M | @@ -911,57 +911,57 @@ Counts by priority: **P0: 10**, **P1: 24** (incl. 2 post-Task #15), **P2: 57** ( - **Estimated complexity**: M - **Rationale**: ID-006 covers `identity_index` boundaries; `key_index` is the parallel axis and currently uncovered. -#### ID-007 — Identity-auth addresses are visible to SPV monitor +#### ID-007 — Identity-auth addresses are intentionally NOT monitored - **Priority**: P2 -- **Status**: FAILING — by design until upstream lands BlockchainIdentities* - support. The test asserts the CORRECT behavior (identity-auth addresses - ARE monitored, Core balance DOES increase, UTXO set holds the new entry). - Will start passing when rust-dashcore's `WalletAccountCreationOptions::Default` - exposes the identity-authentication subfeature paths. Tracks closed PR - `dashpay/rust-dashcore#554` / DET issue `dash-evo-tool#692`. Test body lives - at `tests/e2e/cases/id_007_identity_auth_addresses_monitored.rs`; - `#[ignore]`-tagged so a default `cargo test` stays green. End-to-end runs - (`cargo test -- --ignored`) currently FAIL by design — green = feature - works, red = feature broken. Framework prerequisites are cleared (SPV - runtime live, `BankWallet::send_core_to` implemented), and runs are - gated on **operator pre-funding the bank's Core (Layer-1) receive address** - with at least `100_000 + fee` duffs of testnet DASH (the address is logged - at framework init under target `platform_wallet::e2e::bank`). -- **Wallet feature exercised**: `PlatformWalletInfo::monitored_addresses` (`wallet/platform_wallet_traits.rs:93`) projection for DIP-9 identity-authentication addresses derived via `derive_ecdsa_identity_auth_keypair_from_master` (`wallet/identity/network/identity_handle.rs:143`). Concretely: the `m/9'/coinType'/5'/0'/identity_index'/key_index'` subfeature path, which is not in `WalletAccountCreationOptions::Default` at the pinned `key-wallet` revision. -- **DET parallel**: `dash-evo-tool#692` (the follow-up issue PR `dashpay/rust-dashcore#554` referenced for the DET-side `spv_account_metadata()` match arm). +- **Status**: Pass — `tests/e2e/cases/id_007_identity_auth_addresses_not_monitored.rs` + pins the intentional architecture that DIP-9 identity-authentication + subfeature paths (subfeature `0..3`, + `m/9'/coinType'/5'/{0,1,2,3}'/identity_index'/key_index'`) are NOT in + `WalletAccountCreationOptions::Default` and therefore NOT in + `PlatformWalletInfo::monitored_addresses()`. Sending Core duffs to + one of those addresses does NOT increase the wallet's Core balance, + and the UTXO set never observes such a send. `#[ignore]`-tagged so a + default `cargo test` stays green; `cargo test -- --ignored` runs it + end-to-end and is expected to PASS. Documents the intended + architecture; closed PR `dashpay/rust-dashcore#554` was a speculative + attempt to change this and was correctly rejected. End-to-end runs + are gated on **operator pre-funding the bank's Core (Layer-1) receive + address** with at least `100_000 + fee` duffs of testnet DASH (the + address is logged at framework init under target + `platform_wallet::e2e::bank`). +- **Wallet feature exercised**: `PlatformWalletInfo::monitored_addresses` (`wallet/platform_wallet_traits.rs:93`) projection for DIP-9 identity-authentication addresses derived via `derive_ecdsa_identity_auth_keypair_from_master` (`wallet/identity/network/identity_handle.rs:143`). Concretely: the `m/9'/coinType'/5'/0'/identity_index'/key_index'` subfeature path, which is intentionally excluded from `WalletAccountCreationOptions::Default` because identity-auth keys are pure key material, not funds-bearing addresses. +- **DET parallel**: `dash-evo-tool/src/backend_task/account_summary.rs:226-229` — explicitly states identity-auth addresses "usually hold zero balance"; `receive_address()` returns BIP-44 paths only and DET's UI hides them outside developer-mode "Identity System" view. - **Preconditions**: - SPV runtime enabled (Task #15 — gates `CR-001` too). - ID-001 helper landed (Wave A). - - Bank wallet that holds **Core coins**, not just credits — same prerequisite as `CR-003`. Test is gated until that Core-funded helper exists. + - Bank wallet that holds **Core coins**, not just credits — same prerequisite as `CR-003`. - **Scenario**: 1. `let id = setup_with_n_identities(1, 30_000_000).await?.identities[0];` 2. Compute `auth_addr = P2PKH(derive_ecdsa_identity_auth_keypair_from_master(master, network, identity_index = 0, key_index = 0).public_key)`. 3. Snapshot `wallet.monitored_addresses()` *before* sending anything. - 4. Send `100_000` duffs from the Core-funded bank to `auth_addr` on Layer-1; wait for instant-lock. + 4. Send `100_000` duffs from the Core-funded bank to `auth_addr` on Layer-1. 5. Snapshot `wallet.monitored_addresses()` *after* the broadcast. - 6. Wait up to `30s` for the wallet's Core balance to reflect the incoming UTXO; record whether it does. -- **Assertions** (pin the **correct** contract — green when the feature works, red while upstream remains unfixed): - - `auth_addr` **IS** in `monitored_addresses()` both before and after step 4. - - The wallet's Core balance **DOES** increase to at least `pre_balance + 1` within the confirmation window after step 6. - - The wallet's UTXO set **DOES** contain the new `100_000`-duff UTXO at `auth_addr`. - - All three currently fail because `WalletAccountCreationOptions::Default` excludes `BlockchainIdentities*` accounts at the pinned `key-wallet` revision; the test starts passing when upstream lands the fix. -- **Variants** (covered inline in the same test — registration status is irrelevant, the derivation is pure): - - Compute `auth_addr` for `identity_index = 1` (an unregistered slot) — same correct-behavior assertions hold (the address must be monitored regardless of registration state). - - Repeat for the BLS subfeature path (`m/9'/coinType'/5'/2'/identity_index'/key_index'`) once `derive_*_bls_identity_auth_keypair_from_master` lands; assert the same correct behavior. (Deferred — TODO comment in the test body.) + 6. Wait up to `30s` for the wallet's Core balance to reflect the incoming UTXO; expect it does NOT. +- **Assertions** (pin the **intended** contract — green when the architecture is intact): + - `auth_addr` is **NOT** in `monitored_addresses()` both before and after step 4. + - The wallet's Core balance does **NOT** increase to `pre_balance + 1` within the negative window after step 6 (the `wait_for_core_balance` call is expected to time out). + - The wallet's UTXO set does **NOT** contain a `100_000`-duff UTXO at `auth_addr`. + - When this test starts FAILING, a regression has happened: either `WalletAccountCreationOptions::Default` started including `BlockchainIdentities*` `AccountType`s, or some other code path has begun monitoring these addresses without architecture review. Investigate before flipping. +- **Variants** (covered inline in the same test — registration status is irrelevant, the derivation is pure; same architecture applies): + - Compute `auth_addr` for `identity_index = 1` (an unregistered slot) — the address must remain unmonitored regardless of registration state. + - Repeat for the BLS subfeature path (`m/9'/coinType'/5'/2'/identity_index'/key_index'`) once `derive_*_bls_identity_auth_keypair_from_master` lands; same intended-contract assertions apply. (Deferred — TODO comment in the test body.) - **Harness extensions required**: - SPV runtime re-enabled (Task #15 — same prerequisite as `CR-001`). - - Core-funded bank wallet helper (same prerequisite as `CR-003`). Stubbed for now via `Bank::send_core_to(..) -> unimplemented!()`; wire through when CR-003 helpers land. + - Core-funded bank wallet helper (same prerequisite as `CR-003`). - `wait_for_core_balance(wallet, expected_min, timeout)` — landed in `framework/wait.rs` alongside this case (parallel of `wait_for_balance` for Layer-1 balance instead of credits). - Wave A's `SeedBackedIdentitySigner` (already needed for `ID-001`). - **Estimated complexity**: M (test body is short — most of the cost is the prerequisite SPV + Core-faucet bring-up that `CR-001` and `CR-003` already require). - **Funding budget**: `100_000` Core duffs (~0.001 DASH) per run for the Layer-1 send; rounding for Core-tx fee. Negligible compared to the credit budget of any P0/P1 case. -- **Rationale**: Pins the **correct** contract for "which DIP-9 subfeatures get monitored?" The closed PR `dashpay/rust-dashcore#554` user story explicitly called out identity-auth addresses as a scenario it wanted SPV-monitored; the PR is closed without merge or supersede pointer, and the current contract in the pinned `key-wallet` rev silently excludes them. ID-007 inverts the polarity of the previous defensive-pin: instead of asserting the broken behavior holds (green while the bug exists, misleading), the test asserts the correct behavior and FAILS today. That way: - 1. anyone who flips `WalletAccountCreationOptions::Default` to include `BlockchainIdentities*` accounts (or any equivalent reshape upstream) sees this test go green, signalling the feature is fixed; - 2. nobody on the platform side mistakes a green ID-007 for "the feature works" while it doesn't — broken feature stays red. +- **Rationale**: Pins the **intentional** architecture for "which DIP-9 subfeatures get monitored?" Identity-auth addresses are pure key material — they sign identity state transitions, they don't receive Layer-1 Dash. dash-evo-tool (the canonical Platform client) treats them this way: `account_summary.rs:226-229` explicitly notes they "usually hold zero balance"; `receive_address()` returns BIP-44 paths only; the UI hides them outside developer-mode "Identity System" view. No standard flow sends Layer-1 Dash to these addresses. The closed PR `dashpay/rust-dashcore#554` was a speculative attempt to change this for a hypothetical use case, not a fix for any active bug — its rejection was correct. ID-007 pins the not-monitored contract so any accidental regression — or any deliberate architecture shift — surfaces loudly. - **Operator notes**: First cold-cache run takes ~15 minutes because SPV walks compact filters from genesis (~1.47M testnet blocks). Subsequent runs reuse the on-disk cache and complete in seconds. The harness gates init on `PLATFORM_WALLET_E2E_BANK_CORE_GATE` (default `0` — skip); set it to at least `110_000` (`100_000` send + `~10_000` fee reserve) before invoking ID-007 so the bank's `core_balance_confirmed` reflects the post-scan total instead of a false-zero mid-scan. Set `RUST_LOG=info,platform_wallet::e2e::wait=info` to see scan-progress lines (`scan_height` vs `scan_tip`) every 30s. - **Notes**: - Today `derive_ecdsa_identity_auth_keypair_from_master` is the only DIP-9 subfeature `rs-platform-wallet` exposes (subfeature 0, ECDSA). Adding the BLS / Hash160 variants is contingent on the upstream `key-wallet` API gaining BLS derivation helpers. - - This is a **failing-by-design feature test**: it asserts the correct end-state and stays red until upstream lands the fix. Contrast with `Found-003` / `Found-004` (defensive pins of broken behavior, green-while-broken — kept where the bug is the contract). ID-007 inverts that polarity because identity-auth monitoring is a feature people will eventually depend on; pretending it works (green) would be misleading. + - This is a **defensive pin of intentional behavior**, in the same family as `Found-003` / `Found-004`: green = architecture intact, red = something changed and needs review. The change might be a real architecture shift (in which case flip the assertions in the same PR that wires the change) or an accident (in which case revert the breakage). ### Tokens (TK) diff --git a/packages/rs-platform-wallet/tests/e2e/cases/id_007_identity_auth_addresses_monitored.rs b/packages/rs-platform-wallet/tests/e2e/cases/id_007_identity_auth_addresses_monitored.rs deleted file mode 100644 index 586245d2a6..0000000000 --- a/packages/rs-platform-wallet/tests/e2e/cases/id_007_identity_auth_addresses_monitored.rs +++ /dev/null @@ -1,258 +0,0 @@ -//! ID-007 — Identity-auth addresses ARE visible to SPV monitor. -//! -//! Spec: `tests/e2e/TEST_SPEC.md` (### Identity (ID) → ID-007). -//! Pinned status: FAILING — documents an open upstream issue. -//! -//! Asserts the CORRECT behavior: -//! - identity-auth addresses derived via -//! [`derive_ecdsa_identity_auth_keypair_from_master`] ARE in -//! [`WalletInfoInterface::monitored_addresses`]. -//! - Sending Core duffs to one of those addresses INCREASES the -//! wallet's Core balance. -//! - The wallet's UTXO set ends up holding the new UTXO. -//! -//! This test currently FAILS because rust-dashcore's -//! `WalletAccountCreationOptions::Default` does not include the -//! `BlockchainIdentities*` `AccountType` variants (closed PR -//! `dashpay/rust-dashcore#554` attempted this; closed without -//! merge). When upstream lands the fix and exposes those accounts as -//! part of `Default`, this test will start passing — and that's the -//! point: green = feature works, red = feature broken. -//! -//! DET parallel: `dash-evo-tool#692` (the follow-up issue PR -//! `dashpay/rust-dashcore#554` referenced for the DET-side -//! `spv_account_metadata()` match arm). - -use std::time::Duration; - -use dashcore::secp256k1::PublicKey as SecpPublicKey; -use dashcore::{Address, Network, PublicKey}; -use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; -use key_wallet::wallet::root_extended_keys::RootExtendedPrivKey; -use platform_wallet::wallet::identity::network::derive_ecdsa_identity_auth_keypair_from_master; - -use crate::framework::prelude::*; - -/// Funding committed to the registered identity. Modest — the -/// scenario doesn't need a fat identity, only one that exists so the -/// `identity_index = 0` slot is canonically "in use". -const REGISTRATION_FUNDING: u64 = 30_000_000; - -/// Layer-1 send amount targeted at the identity-auth address. ~0.001 -/// DASH; well above the dust threshold so the bank's Core path -/// doesn't reject it on amount alone, well below any per-test budget -/// concern. -const CORE_SEND_DUFFS: u64 = 100_000; - -/// Window for `wait_for_core_balance` to observe the inbound UTXO at -/// confirmed depth. The waiter polls -/// [`TestWallet::core_balance_confirmed`] (see -/// `framework/wait.rs`), which only counts confirmed UTXOs. Testnet -/// block time is ~2.5 minutes; allow generous headroom for one -/// confirmation plus SPV bloom-filter propagation. -const CORE_BALANCE_CONFIRMATION_WINDOW: Duration = Duration::from_secs(300); - -#[ignore = "ID-007 — pins upstream rust-dashcore#554 / blockchain-identities work; \ - currently FAILS by design until WalletAccountCreationOptions::Default \ - includes BlockchainIdentities* AccountType variants. Run with \ - `cargo test -- --ignored` expecting failure. When this test starts \ - passing, the upstream fix has landed."] -#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] -async fn id_007_identity_auth_addresses_monitored() { - 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: register one identity at slot 0 with modest funding. - // Reuses `setup_with_n_identities` so the canonical identity- - // funding path is exercised; the identity itself isn't load- - // bearing in the assertions, only that slot 0 is "in use". - let s = crate::framework::setup_with_n_identities(1, REGISTRATION_FUNDING) - .await - .expect("setup_with_n_identities failed"); - let identity_zero = s - .identities - .first() - .expect("setup_with_n_identities returned no identities"); - tracing::info!( - target: "platform_wallet::e2e::cases::id_007", - identity_id = %identity_zero.id, - "registered slot-0 identity for ID-007" - ); - - let network = s.base.ctx.config.network; - let seed_bytes = s.base.test_wallet.seed_bytes(); - - // Derive `auth_addr` for (identity_index = 0, key_index = 0) — - // the slot we just registered. Pure derivation; bypasses the - // wallet's `AccountCollection` entirely. P2PKH the resulting - // pubkey to get a Core (Layer-1) address. - let auth_addr_zero = derive_auth_address(&seed_bytes, network, 0, 0) - .expect("derive identity-auth address (identity_index=0, key_index=0)"); - - // Negative-axis variant — same derivation at an UNREGISTERED - // slot. Registration status is irrelevant to monitoring (the - // derivation is pure), so the same correct-behavior assertions - // hold: every (identity_index, key_index) pair under the DIP-9 - // identity-authentication subfeature MUST be monitored. - let auth_addr_one = derive_auth_address(&seed_bytes, network, 1, 0) - .expect("derive identity-auth address (identity_index=1, key_index=0)"); - - // TODO(ID-007): add BLS subfeature variant once - // `derive_*_bls_identity_auth_keypair_from_master` lands in the - // upstream `key-wallet` API. Path: - // `m/9'/coinType'/5'/2'/identity_index'/key_index'`. Same - // correct-behavior assertions apply. - - // Step 3: snapshot `monitored_addresses()` BEFORE any Core send. - // Once upstream lands the fix, both addresses MUST already be in - // the monitored set (the bloom filter regenerates from - // `accounts.all_accounts()` and `BlockchainIdentities*` accounts - // are part of `WalletAccountCreationOptions::Default`). - let monitored_before = s - .base - .test_wallet - .platform_wallet() - .state() - .await - .monitored_addresses(); - assert!( - monitored_before.contains(&auth_addr_zero), - "identity-auth address (slot 0) is NOT in monitored_addresses() \ - before the Core send. Expected the SPV bloom filter to cover \ - every (identity_index, key_index) pair on the DIP-9 \ - identity-authentication subfeature path. This assertion will \ - start passing when upstream rust-dashcore exposes \ - BlockchainIdentities* AccountType variants in \ - WalletAccountCreationOptions::Default \ - (closed PR dashpay/rust-dashcore#554; DET parallel \ - dash-evo-tool#692)." - ); - assert!( - monitored_before.contains(&auth_addr_one), - "identity-auth address (slot 1, unregistered) is NOT in \ - monitored_addresses(). Registration status is irrelevant — \ - the derivation is pure — so every (identity_index, key_index) \ - pair on the DIP-9 identity-authentication subfeature path \ - MUST be monitored. Tracks closed PR dashpay/rust-dashcore#554." - ); - - // Step 4: send `CORE_SEND_DUFFS` from the bank to `auth_addr_zero` - // on Layer-1 via `BankWallet::send_core_to` (CR-003). Returns a - // broadcast `Txid`; we wait below for confirmation via - // `wait_for_core_balance`. - // Use the same lock-free confirmed-balance accessor that - // `wait_for_core_balance` polls — pinning `pre_balance + 1` against - // the same metric the waiter compares against keeps the assertion - // crisp. - let pre_balance = s.base.test_wallet.core_balance_confirmed(); - let _txid = s - .base - .ctx - .bank() - .send_core_to(&auth_addr_zero, CORE_SEND_DUFFS) - .await - .expect("bank.send_core_to (CR-003 prerequisite)"); - - // Step 5: snapshot `monitored_addresses()` AFTER the broadcast. - // The bloom filter is regenerated from `accounts.all_accounts()`; - // identity-auth addresses MUST still appear post-broadcast. - let monitored_after = s - .base - .test_wallet - .platform_wallet() - .state() - .await - .monitored_addresses(); - assert!( - monitored_after.contains(&auth_addr_zero), - "identity-auth address (slot 0) is NOT in monitored_addresses() \ - after the Layer-1 send. Upstream BlockchainIdentities* support \ - is required for the SPV bloom filter to cover this path \ - (rust-dashcore#554)." - ); - assert!( - monitored_after.contains(&auth_addr_one), - "identity-auth address (slot 1, unregistered) is NOT in \ - monitored_addresses() after the Layer-1 send. Registration \ - status is irrelevant; every (identity_index, key_index) pair \ - on the DIP-9 identity-authentication subfeature path must be \ - monitored (rust-dashcore#554)." - ); - - // Step 6: wait UP TO `CORE_BALANCE_CONFIRMATION_WINDOW` for the - // wallet's confirmed Core balance to reflect the inbound UTXO. - // With the upstream fix in place, the SPV bloom filter carries - // `auth_addr_zero` and the inbound UTXO becomes visible once - // confirmed. - let observed = wait_for_core_balance( - &s.base.test_wallet, - pre_balance + 1, - CORE_BALANCE_CONFIRMATION_WINDOW, - ) - .await - .expect( - "wait_for_core_balance timed out waiting for the inbound \ - UTXO at the identity-auth address. Either the SPV bloom \ - filter doesn't carry DIP-9 subfeature 0..3 (the current \ - upstream state — rust-dashcore#554 not merged), or the send \ - didn't confirm within the window. The test asserts the \ - CORRECT contract; failure here documents the open issue.", - ); - tracing::info!( - target: "platform_wallet::e2e::cases::id_007", - observed, - pre_balance, - delta = observed.saturating_sub(pre_balance), - "wallet observed Core balance increase from identity-auth send" - ); - - // Step 7: snapshot the UTXO set and assert it contains the new - // entry to `auth_addr_zero` for `CORE_SEND_DUFFS`. - let utxo_count_to_auth_addr = s - .base - .test_wallet - .platform_wallet() - .state() - .await - .utxos() - .iter() - .filter(|u| u.value() == CORE_SEND_DUFFS && u.address == auth_addr_zero) - .count(); - assert!( - utxo_count_to_auth_addr >= 1, - "wallet's UTXO set does NOT contain a {CORE_SEND_DUFFS}-duff \ - entry to the identity-auth address. The SPV bloom filter \ - needs to carry DIP-9 subfeature 0..3 \ - (rust-dashcore#554)." - ); - - s.teardown().await.expect("teardown"); -} - -/// Derive the P2PKH `dashcore::Address` for the identity-auth keypair -/// at `(identity_index, key_index)` on `network`. Mirrors the -/// derivation in `framework::signer::derive_identity_key` but stops -/// at the public-key → address step instead of building an -/// `IdentityPublicKey`. -fn derive_auth_address( - seed_bytes: &[u8; 64], - network: Network, - identity_index: u32, - key_index: u32, -) -> Result { - let root_priv = RootExtendedPrivKey::new_master(seed_bytes) - .map_err(|err| format!("invalid seed for root xpriv: {err}"))?; - let master = root_priv.to_extended_priv_key(network); - let derived = - derive_ecdsa_identity_auth_keypair_from_master(&master, network, identity_index, key_index) - .map_err(|err| format!("derive ({identity_index}, {key_index}): {err}"))?; - let secp_pubkey = SecpPublicKey::from_slice(&derived.public_key).map_err(|err| { - format!("public_key bytes from derive are not a valid secp256k1 pubkey: {err}") - })?; - Ok(Address::p2pkh(&PublicKey::new(secp_pubkey), network)) -} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/id_007_identity_auth_addresses_not_monitored.rs b/packages/rs-platform-wallet/tests/e2e/cases/id_007_identity_auth_addresses_not_monitored.rs new file mode 100644 index 0000000000..063ab17a98 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/id_007_identity_auth_addresses_not_monitored.rs @@ -0,0 +1,263 @@ +//! ID-007 — Identity-auth addresses are intentionally NOT monitored. +//! +//! Spec: `tests/e2e/TEST_SPEC.md` (### Identity (ID) → ID-007). +//! Pinned status: Pass — pins the intended architecture. +//! +//! Asserts the CORRECT, intentional contract: +//! - identity-auth addresses (DIP-9 subfeature 0..3, 6-component path +//! `m/9'/coinType'/5'/{0,1,2,3}'/identity_index'/key_index'`) derived +//! via [`derive_ecdsa_identity_auth_keypair_from_master`] are NOT in +//! [`WalletInfoInterface::monitored_addresses`]. They are pure key +//! material — used for signing identity state transitions, NOT for +//! receiving Layer-1 Dash. +//! - Sending Core duffs to one of these addresses does NOT increase +//! the wallet's Core balance — the SPV bloom filter intentionally +//! excludes them. +//! - The UTXO set does NOT contain entries for these addresses. +//! +//! Architecture rationale: +//! - dash-evo-tool (the canonical Platform client) treats these as +//! pure key material; `account_summary.rs:226-229` explicitly states +//! they "usually hold zero balance". +//! - DET's `receive_address()` returns BIP-44 paths only, never +//! identity-auth paths. +//! - DET's UI hides them outside developer-mode "Identity System" +//! view. +//! - No standard flow sends Layer-1 Dash to these addresses. +//! +//! When this test starts FAILING, it means a regression has happened: +//! either `WalletAccountCreationOptions::Default` started including +//! `BlockchainIdentities*` `AccountType`s (the closed +//! `dashpay/rust-dashcore#554` was a speculative attempt), OR some +//! other code path has begun monitoring these addresses without +//! corresponding architecture review. Investigate before flipping the +//! assertions — the change may be a real architecture shift (in which +//! case flip them) or an accident (in which case revert the breakage). + +use std::time::Duration; + +use dashcore::secp256k1::PublicKey as SecpPublicKey; +use dashcore::{Address, Network, PublicKey}; +use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; +use key_wallet::wallet::root_extended_keys::RootExtendedPrivKey; +use platform_wallet::wallet::identity::network::derive_ecdsa_identity_auth_keypair_from_master; + +use crate::framework::prelude::*; + +/// Funding committed to the registered identity. Modest — the +/// scenario doesn't need a fat identity, only one that exists so the +/// `identity_index = 0` slot is canonically "in use". +const REGISTRATION_FUNDING: u64 = 30_000_000; + +/// Layer-1 send amount targeted at the identity-auth address. ~0.001 +/// DASH; well above the dust threshold so the bank's Core path +/// doesn't reject it on amount alone, well below any per-test budget +/// concern. +const CORE_SEND_DUFFS: u64 = 100_000; + +/// Negative-window for `wait_for_core_balance`: the test pins that +/// the Core balance does NOT reach `CORE_SEND_DUFFS` even after this +/// long, so the wait is EXPECTED to time out under the intentional +/// not-monitored contract. 30 seconds matches Marvin's spec. +const CORE_BALANCE_NEGATIVE_WINDOW: Duration = Duration::from_secs(30); + +#[ignore = "ID-007 — pins the intentional architecture that identity-auth \ + addresses are NOT monitored by SPV. Run with `cargo test -- \ + --ignored` expecting it to PASS. If it starts FAILING, the \ + architecture has shifted — investigate before flipping."] +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn id_007_identity_auth_addresses_not_monitored() { + 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: register one identity at slot 0 with modest funding. + // Reuses `setup_with_n_identities` so the canonical identity- + // funding path is exercised; the identity itself isn't load- + // bearing in the assertions, only that slot 0 is "in use". + let s = crate::framework::setup_with_n_identities(1, REGISTRATION_FUNDING) + .await + .expect("setup_with_n_identities failed"); + let identity_zero = s + .identities + .first() + .expect("setup_with_n_identities returned no identities"); + tracing::info!( + target: "platform_wallet::e2e::cases::id_007", + identity_id = %identity_zero.id, + "registered slot-0 identity for ID-007" + ); + + let network = s.base.ctx.config.network; + let seed_bytes = s.base.test_wallet.seed_bytes(); + + // Derive `auth_addr` for (identity_index = 0, key_index = 0) — + // the slot we just registered. Pure derivation; bypasses the + // wallet's `AccountCollection` entirely. P2PKH the resulting + // pubkey to get a Core (Layer-1) address. + let auth_addr_zero = derive_auth_address(&seed_bytes, network, 0, 0) + .expect("derive identity-auth address (identity_index=0, key_index=0)"); + + // Negative-axis variant — same derivation at an UNREGISTERED + // slot. Registration status is irrelevant to monitoring (the + // derivation is pure), so the same intended-contract assertions + // hold: every (identity_index, key_index) pair under the DIP-9 + // identity-authentication subfeature must remain unmonitored. + let auth_addr_one = derive_auth_address(&seed_bytes, network, 1, 0) + .expect("derive identity-auth address (identity_index=1, key_index=0)"); + + // TODO(ID-007): add BLS subfeature variant once + // `derive_*_bls_identity_auth_keypair_from_master` lands in the + // upstream `key-wallet` API. Path: + // `m/9'/coinType'/5'/2'/identity_index'/key_index'`. Same + // intended-contract assertions apply. + + // Step 3: snapshot `monitored_addresses()` BEFORE any Core send. + // The wallet has been live since `setup_with_n_identities` + // returned, so this is the steady-state monitored set — it + // intentionally excludes identity-auth addresses. + let monitored_before = s + .base + .test_wallet + .platform_wallet() + .state() + .await + .monitored_addresses(); + assert!( + !monitored_before.contains(&auth_addr_zero), + "PRE-pin violated: identity-auth address (slot 0) is in \ + monitored_addresses(). DET treats these as pure key material \ + (account_summary.rs:226-229) and the wallet's Default \ + monitored set must not include DIP-9 subfeature 0..3. If \ + this fires, either the architecture has shifted (review \ + before flipping) or an accident has started monitoring \ + these addresses (revert the breakage)." + ); + assert!( + !monitored_before.contains(&auth_addr_one), + "PRE-pin violated: identity-auth address (slot 1, unregistered) \ + is in monitored_addresses(). Registration status is \ + irrelevant — the derivation is pure — so the same intended \ + contract applies to every (identity_index, key_index) pair." + ); + + // Step 4: send `CORE_SEND_DUFFS` from the bank to `auth_addr_zero` + // on Layer-1 via `BankWallet::send_core_to` (CR-003). Returns a + // broadcast `Txid`; we don't wait for instant-lock because the + // intended contract is "the wallet's monitored set never sees + // this". The `wait_for_core_balance` call below bounds + // observation of the (expected absent) UTXO. + // Use the same lock-free confirmed-balance accessor that + // `wait_for_core_balance` polls — pinning `pre_balance + 1` against + // the same metric the waiter compares against keeps the negative + // contract crisp (the timeout fires because `auth_addr_zero` isn't + // in `monitored_addresses()`, not because the two readings drift). + let pre_balance = s.base.test_wallet.core_balance_confirmed(); + let _txid = s + .base + .ctx + .bank() + .send_core_to(&auth_addr_zero, CORE_SEND_DUFFS) + .await + .expect("bank.send_core_to (CR-003 prerequisite)"); + + // Step 5: snapshot `monitored_addresses()` AFTER the broadcast. + // The bloom filter regenerates from `accounts.all_accounts()`, + // which still excludes the BlockchainIdentities subfeature, so + // the set must be unchanged with respect to `auth_addr_*`. + let monitored_after = s + .base + .test_wallet + .platform_wallet() + .state() + .await + .monitored_addresses(); + assert!( + !monitored_after.contains(&auth_addr_zero), + "POST-pin violated (slot 0): identity-auth address appeared in \ + monitored_addresses() after a Layer-1 send. The Default \ + monitored set must remain free of DIP-9 subfeature 0..3 — \ + if it doesn't, the wallet has begun treating identity keys \ + as funds-bearing addresses without architecture review." + ); + assert!( + !monitored_after.contains(&auth_addr_one), + "POST-pin violated (slot 1): identity-auth address for an \ + unregistered slot appeared in monitored_addresses() after a \ + Layer-1 send. The send didn't even target this slot — \ + something has flipped the default monitored set." + ); + + // Step 6: wait UP TO `CORE_BALANCE_NEGATIVE_WINDOW` for the Core + // balance to reflect the inbound UTXO. Per the intended contract + // it MUST NOT — the SPV bloom filter doesn't carry `auth_addr_zero`, + // so the UTXO is invisible to the wallet. We pin the timeout as + // EXPECTED. + let core_wait = wait_for_core_balance( + &s.base.test_wallet, + pre_balance + 1, + CORE_BALANCE_NEGATIVE_WINDOW, + ) + .await; + assert!( + core_wait.is_err(), + "POST-pin violated: wallet observed a Core balance increase \ + after sending to an identity-auth address. The intended \ + contract is that DIP-9 subfeature 0..3 is unmonitored; if \ + this assertion fires, either the SPV path now reaches into \ + that subfeature, or an unrelated UTXO landed concurrently \ + (rare in the isolated test environment). \ + (observed value: {:?})", + core_wait.ok() + ); + + // Step 7: snapshot the UTXO set and assert it does not contain + // a `CORE_SEND_DUFFS`-valued entry to `auth_addr_zero`. + let utxo_count_to_auth_addr = s + .base + .test_wallet + .platform_wallet() + .state() + .await + .utxos() + .iter() + .filter(|u| u.value() == CORE_SEND_DUFFS && u.address == auth_addr_zero) + .count(); + assert_eq!( + utxo_count_to_auth_addr, 0, + "POST-pin violated: the wallet's UTXO set contains a \ + {CORE_SEND_DUFFS}-duff entry to the identity-auth address. \ + The intended contract is that the SPV bloom filter does not \ + carry DIP-9 subfeature 0..3 — investigate before flipping \ + the assertions." + ); + + s.teardown().await.expect("teardown"); +} + +/// Derive the P2PKH `dashcore::Address` for the identity-auth keypair +/// at `(identity_index, key_index)` on `network`. Mirrors the +/// derivation in `framework::signer::derive_identity_key` but stops +/// at the public-key → address step instead of building an +/// `IdentityPublicKey`. +fn derive_auth_address( + seed_bytes: &[u8; 64], + network: Network, + identity_index: u32, + key_index: u32, +) -> Result { + let root_priv = RootExtendedPrivKey::new_master(seed_bytes) + .map_err(|err| format!("invalid seed for root xpriv: {err}"))?; + let master = root_priv.to_extended_priv_key(network); + let derived = + derive_ecdsa_identity_auth_keypair_from_master(&master, network, identity_index, key_index) + .map_err(|err| format!("derive ({identity_index}, {key_index}): {err}"))?; + let secp_pubkey = SecpPublicKey::from_slice(&derived.public_key).map_err(|err| { + format!("public_key bytes from derive are not a valid secp256k1 pubkey: {err}") + })?; + Ok(Address::p2pkh(&PublicKey::new(secp_pubkey), network)) +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/mod.rs b/packages/rs-platform-wallet/tests/e2e/cases/mod.rs index d87abd95a9..e316c2a97e 100644 --- a/packages/rs-platform-wallet/tests/e2e/cases/mod.rs +++ b/packages/rs-platform-wallet/tests/e2e/cases/mod.rs @@ -12,7 +12,7 @@ pub mod id_001_register_identity_from_addresses; pub mod id_002_top_up_identity; pub mod id_003_identity_to_identity_transfer; pub mod id_005_identity_to_addresses_transfer; -pub mod id_007_identity_auth_addresses_monitored; +pub mod id_007_identity_auth_addresses_not_monitored; pub mod id_sweep_recovers_identity_credits; pub mod pa_001_multi_output; pub mod pa_001b_change_address_branch;