diff --git a/.github/package-filters/js-packages-no-workflows.yml b/.github/package-filters/js-packages-no-workflows.yml index 91f3a58a79e..1ecb9012439 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/** diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d679990f4f2..7ebf8495720 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' }} diff --git a/packages/dashmate/configs/defaults/getBaseConfigFactory.js b/packages/dashmate/configs/defaults/getBaseConfigFactory.js index 615381179b1..e21b2111e88 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 409c36b1cee..9c9120b345f 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 a8d6018cb5e..adaa072685d 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 3b53f61a5b2..ecf969bde64 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 c07f8f18cba..c86b84bcccd 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}} diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 37661da3502..b875769a844 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 20e6db8cd81..26913d5228a 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 1042feb440a..ef44ebb28f1 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, diff --git a/packages/rs-platform-wallet/src/spv/runtime.rs b/packages/rs-platform-wallet/src/spv/runtime.rs index eecb0e58607..2e3d8daa40c 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/TEST_SPEC.md b/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md index ee69243eebc..01ad8a03494 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 @@ -131,6 +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 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 | @@ -193,7 +212,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 +911,58 @@ 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 intentionally NOT monitored +- **Priority**: P2 +- **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`. +- **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. + 5. Snapshot `wallet.monitored_addresses()` *after* the broadcast. + 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`). + - `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 **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 **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) The wallet has token operations on the API surface @@ -1311,8 +1382,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**: 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). - **Scenario**: build asset-lock tx; wait for instant-lock; register identity. @@ -1321,6 +1392,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) 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 00000000000..ea4e7000310 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/cr_003_asset_lock_funded_registration.rs @@ -0,0 +1,282 @@ +//! 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::balances::credits::CREDITS_PER_DUFF; +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. 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, + expected_credits_min, + IDENTITY_VISIBILITY_TIMEOUT, + ) + .await + .expect("identity balance (credits) reached half-lock threshold"); + assert!( + 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 + // 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/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 00000000000..063ab17a984 --- /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 8f40c4f3337..e316c2a97e6 100644 --- a/packages/rs-platform-wallet/tests/e2e/cases/mod.rs +++ b/packages/rs-platform-wallet/tests/e2e/cases/mod.rs @@ -6,11 +6,13 @@ //! 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; 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/cases/pa_004_sweep_back.rs b/packages/rs-platform-wallet/tests/e2e/cases/pa_004_sweep_back.rs index 9fb2968bc79..ab4333b315f 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 f03800884c1..7e44b613e4e 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 b7e85c7f954..9ef82d84966 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 130e5bbbdff..de397e39a12 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; @@ -165,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)?; @@ -363,12 +372,143 @@ impl BankWallet { pub fn funding_mutex_history(&self) -> Vec { drain_funding_mutex_history() } + + /// 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() + } + + /// 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 + /// 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`. + /// + /// Thin wrapper over [`core_send`]: serialises on + /// [`FUNDING_MUTEX`] so concurrent Core / Platform funding flows + /// 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 + /// 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` (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 _guard = FUNDING_MUTEX.lock().await; + + 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 so operators + // hit the same documented format whether the bank is + // Platform-credit or Core-duff under-funded. + 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 txid = core_send(&self.wallet, target, duffs).await?; + tracing::info!( + target: "platform_wallet::e2e::bank", + %txid, + target = %target, + duffs, + "bank.send_core_to broadcast" + ); + Ok(txid) + } } 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/cleanup.rs b/packages/rs-platform-wallet/tests/e2e/framework/cleanup.rs index 1739e2f67d1..8a93726a5a6 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; @@ -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 { @@ -138,7 +143,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 +196,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 +535,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/config.rs b/packages/rs-platform-wallet/tests/e2e/framework/config.rs index 0dee6820570..bed29ca5f6e 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/harness.rs b/packages/rs-platform-wallet/tests/e2e/framework/harness.rs index 4d16dc161e0..2a6e7d387a0 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,26 @@ use super::cleanup; 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; +/// 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); + +/// 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(); @@ -44,8 +61,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 +114,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,33 +151,117 @@ 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?; + // 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. 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". + // 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, + 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, + 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 // destination on its very first invocation. diff --git a/packages/rs-platform-wallet/tests/e2e/framework/mod.rs b/packages/rs-platform-wallet/tests/e2e/framework/mod.rs index c6d3cf576c9..10a5e95a367 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}; + 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}; } @@ -205,13 +207,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?; @@ -247,6 +252,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. /// diff --git a/packages/rs-platform-wallet/tests/e2e/framework/spv.rs b/packages/rs-platform-wallet/tests/e2e/framework/spv.rs index 066037713db..c479d3ad4e7 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( diff --git a/packages/rs-platform-wallet/tests/e2e/framework/wait.rs b/packages/rs-platform-wallet/tests/e2e/framework/wait.rs index d7e0dd86890..49268f79137 100644 --- a/packages/rs-platform-wallet/tests/e2e/framework/wait.rs +++ b/packages/rs-platform-wallet/tests/e2e/framework/wait.rs @@ -11,12 +11,15 @@ 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 platform_wallet::SpvRuntime; +use super::bank::BankWallet; use super::wallet_factory::TestWallet; use super::{FrameworkError, FrameworkResult}; @@ -123,6 +126,212 @@ pub async fn wait_for_balance( } } +/// 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. +/// +/// **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 +/// `monitored_addresses()`, so a Core send to one MUST time out +/// here at the pinned `key-wallet` revision). +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; + 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, + 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 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 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 3701d42fd4f..48d25d032e9 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)?; @@ -199,6 +200,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 diff --git a/packages/rs-platform-wallet/tests/spv_sync.rs b/packages/rs-platform-wallet/tests/spv_sync.rs index 86011d5ea84..fb621bafcd0 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"); diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 6d134fe2fb3..59c7067e5ee 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 c7c3f683036..345ff652779 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/Core/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index 96bb249295c..c49b1fbb766 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() } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift index bce20865780..69caa96bef1 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/Views/OptionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift index 62272919947..4d82cb3f85b 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. diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift new file mode 100644 index 00000000000..28d8d32626a --- /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] + } +}