diff --git a/packages/rs-platform-wallet/tests/e2e/cases/cr_001_spv_mn_list_sync_readiness.rs b/packages/rs-platform-wallet/tests/e2e/cases/cr_001_spv_mn_list_sync_readiness.rs new file mode 100644 index 0000000000..3c01cbd7d1 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/cr_001_spv_mn_list_sync_readiness.rs @@ -0,0 +1,120 @@ +//! CR-001 — SPV mn-list sync readiness. +//! +//! Spec: `tests/e2e/TEST_SPEC.md` (### Core (CR) → CR-001). +//! +//! Pins the SPV-readiness contract: the mn-list manager reaches +//! `SyncState::Synced`, the synced height is > 0, and the SPV runtime +//! is in a started (running) state on return. The spec's informational +//! warm-cache target is 180 s; the helper internally raises every +//! request to its `COLD_CACHE_TIMEOUT_FLOOR` (600 s) so the actual +//! wait can be longer on a cold testnet cache. +//! +//! The harness already calls `wait_for_mn_list_synced` during +//! `E2eContext::build`; this test re-asserts the same contract from the +//! test-body perspective to keep the pin explicit and independently +//! verifiable. The call returns immediately when the harness already +//! cleared the gate. +//! +//! Mirrors DET's `test_spv_sync_and_create_wallet` at +//! `dash-evo-tool/tests/backend-e2e/spv_wallet.rs:14`. + +use std::time::Duration; + +use crate::framework::config::{spv_disabled_from_env, vars}; +use crate::framework::prelude::*; +use crate::framework::spv::wait_for_mn_list_synced; + +/// Spec's informational warm-cache target for mn-list sync. NOT a hard +/// ceiling: `wait_for_mn_list_synced` raises every request to its +/// internal `COLD_CACHE_TIMEOUT_FLOOR` (600 s — see +/// `framework::spv::wait_for_mn_list_synced`) and emits an `info!` +/// when it does. The real wait is bounded by the floor, not by this +/// constant. +const MN_LIST_SYNC_TIMEOUT: Duration = Duration::from_secs(180); + +#[ignore = "CR-001 — needs testnet + SPV runtime. \ + Set PLATFORM_WALLET_E2E_DISABLE_SPV=0 (or unset) and supply \ + DAPI endpoints via PLATFORM_WALLET_E2E_DAPI_ADDRESSES. \ + Mirrors DET's test_spv_sync_and_create_wallet."] +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn cr_001_spv_mn_list_sync_readiness() { + 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(); + + // Respect the operator escape hatch — when SPV is disabled the mn-list + // will never sync; skip with an informative message rather than burn + // the full timeout. + if spv_disabled_from_env() { + tracing::info!( + target: "platform_wallet::e2e::cases::cr_001", + var = vars::DISABLE_SPV, + "SPV disabled via env — skipping CR-001 \ + (mn-list will never sync without a live SPV runtime)" + ); + return; + } + + let s = crate::framework::setup().await.expect("setup failed"); + + // Step 1: bind the SPV runtime. `E2eContext::build` always starts + // SPV unconditionally, so `ctx.spv()` is `Some` in every supported + // configuration; the `expect` is defence-in-depth in case a future + // harness change makes the runtime optional. + let spv = s + .ctx + .spv() + .expect("PRE-pin violated: ctx.spv() is None — harness must always start SPV"); + + // Step 2: re-pin mn-list sync from the test body. The harness + // already ran this at init, so the call returns immediately when + // already synced; on a cold cache the helper waits up to its + // `COLD_CACHE_TIMEOUT_FLOOR` (600 s). + wait_for_mn_list_synced(spv, MN_LIST_SYNC_TIMEOUT) + .await + .expect("wait_for_mn_list_synced failed before the cold-cache floor elapsed"); + + // Step 3: read the mn-list height from the live sync progress. + let progress = spv.sync_progress().await.expect( + "PRE-pin violated: sync_progress() returned None after \ + wait_for_mn_list_synced succeeded — SPV client must be running", + ); + let mn = progress + .masternodes() + .expect("SyncProgress::masternodes() failed after successful mn-list sync"); + let mn_height = mn.current_height(); + + tracing::info!( + target: "platform_wallet::e2e::cases::cr_001", + mn_height, + state = ?mn.state(), + "CR-001: mn-list synced" + ); + + // Assertion 1: mn-list height > 0 (proves the client synced real data, + // not just initialised with a zero-height placeholder). + assert!( + mn_height > 0, + "POST-pin violated: mn-list height is 0 after sync — \ + the mn-list manager must advance at least one block to report Synced. \ + Check SPV peer connectivity and mn-list initial-sync logic." + ); + + // Assertion 2: SPV runtime is started (running). `is_started()` returns + // `true` when the internal DashSpvClient is initialised and the sync + // loop is live. This is the available proxy for the spec's "Ready state" + // contract (SpvHealth is not yet a public type — see TEST_SPEC.md CR-001 + // harness-extensions note). + assert!( + spv.is_started(), + "POST-pin violated: SpvRuntime::is_started() is false after \ + wait_for_mn_list_synced returned Ok — the runtime must remain \ + started (running) throughout the test session." + ); + + 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 e316c2a97e..0b9e09ea83 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_001_spv_mn_list_sync_readiness; pub mod cr_003_asset_lock_funded_registration; pub mod dpns_001_register_name; pub mod id_001_register_identity_from_addresses; diff --git a/packages/rs-platform-wallet/tests/e2e/framework/config.rs b/packages/rs-platform-wallet/tests/e2e/framework/config.rs index 903e789f9f..8d96205e08 100644 --- a/packages/rs-platform-wallet/tests/e2e/framework/config.rs +++ b/packages/rs-platform-wallet/tests/e2e/framework/config.rs @@ -52,6 +52,14 @@ pub mod vars { /// that don't need Core duffs; any positive integer overrides the /// timeout (in seconds). pub const BANK_CORE_GATE: &str = "PLATFORM_WALLET_E2E_BANK_CORE_GATE"; + /// Operator escape hatch for SPV-gated cases (CR-001, anything + /// asserting on `SpvRuntime` post-conditions). When truthy + /// (`1` / `true` / `yes` / `on`, case-insensitive), the case body + /// skips with an informative log. The harness itself does NOT + /// read this flag — `E2eContext::build` always starts SPV; the + /// gate is consumed test-side via [`super::spv_disabled_from_env`]. + /// See `TEST_SPEC.md` CR-001 for the SPEC-level reference. + pub const DISABLE_SPV: &str = "PLATFORM_WALLET_E2E_DISABLE_SPV"; } /// Default deadline for the bank Core funding gate when the env var is @@ -359,6 +367,34 @@ pub(crate) fn parse_bank_core_gate(raw: Option<&str>) -> (Option, Bank } } +/// Returns `true` when [`vars::DISABLE_SPV`] is set to a truthy value +/// (`1` / `true` / `yes` / `on`, case-insensitive, surrounding +/// whitespace ignored). Any other value — including unset, empty, or +/// unrecognised — returns `false`. +/// +/// SPV-gated cases (e.g. CR-001) call this at the top of the test body +/// and `return` early when it reports `true`, so the operator can opt +/// out of SPV-only assertions without burning the cold-cache timeout. +/// The harness itself never reads the flag: `E2eContext::build` always +/// starts SPV. +pub fn spv_disabled_from_env() -> bool { + is_truthy_env(vars::DISABLE_SPV) +} + +/// Truthy-env helper shared by SPV-style boolean flags. Reads `key` +/// from the process environment and returns `true` for `1` / `true` / +/// `yes` / `on` (case-insensitive, trimmed); everything else — unset, +/// empty, or unrecognised — returns `false`. +fn is_truthy_env(key: &str) -> bool { + matches!( + std::env::var(key).ok().as_deref().map(str::trim), + Some(v) if v == "1" + || v.eq_ignore_ascii_case("true") + || v.eq_ignore_ascii_case("yes") + || v.eq_ignore_ascii_case("on") + ) +} + /// Parse a network string supporting the canonical dashcore names /// plus the test-harness `local` alias for regtest and an empty /// shorthand for testnet. Used only at [`Config`] construction; @@ -434,4 +470,38 @@ mod tests { assert_eq!(timeout, Some(DEFAULT_BANK_CORE_GATE_TIMEOUT)); assert_eq!(src, BankCoreGateSource::EnvInvalidFallback); } + + /// Process-wide env-var flag used to exercise [`is_truthy_env`]. + /// Distinct from any production var so cargo-test parallelism with + /// the `from_env` callers can never collide. The truthy/falsy + /// matrix is exercised in a single test so the two halves don't + /// race over the same key under parallel cargo-test execution. + const TRUTHY_PROBE_VAR: &str = "PLATFORM_WALLET_E2E_TEST_TRUTHY_PROBE"; + + #[test] + fn is_truthy_env_matrix() { + // SAFETY: single-threaded — the probe key is unique to this + // test, so no parallel test can mutate it underneath us. + std::env::remove_var(TRUTHY_PROBE_VAR); + assert!(!is_truthy_env(TRUTHY_PROBE_VAR), "unset must be falsy"); + + for raw in [ + "1", "true", "TRUE", "True", "yes", "Yes", "YES", "on", "ON", " on ", " 1\t", + ] { + std::env::set_var(TRUTHY_PROBE_VAR, raw); + assert!( + is_truthy_env(TRUTHY_PROBE_VAR), + "{raw:?} should be recognised as truthy" + ); + } + + for raw in ["", " ", "0", "false", "no", "off", "disabled", "abc"] { + std::env::set_var(TRUTHY_PROBE_VAR, raw); + assert!( + !is_truthy_env(TRUTHY_PROBE_VAR), + "{raw:?} must NOT be recognised as truthy" + ); + } + std::env::remove_var(TRUTHY_PROBE_VAR); + } }