From 62225fb8b67467c12086455a8f71eb062fc34933 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 1 Apr 2026 17:09:26 +0200 Subject: [PATCH 01/10] refactor(app): lazy network contexts, unified network switch, MCP network tools Rebased PR #803 onto current v1.0-dev by diffing against the squash-merged PR #767 base. Single commit replacing 57 granular commits that had interleaved merges from squash-merged branches. Key changes: - Defer non-active network context creation until switch - Simplify network switch to single BackendTask::SwitchNetwork - Add MCP tools: network_switch, network_refresh_endpoints - Unify context storage for MCP network operations - Force SPV backend in headless mode - Add user-friendly token validation error messages - Various SPV and shielded wallet fixes Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 2 +- CONTRIBUTING.md | 2 +- Cargo.lock | 66 +- Cargo.toml | 4 +- docs/MCP.md | 8 + ...CKEND_TASKS.md => MCP_TOOL_DEVELOPMENT.md} | 4 +- .../ux-spec.md | 396 ++++++++ .../wireframe.html | 515 ++++++++++ src/app.rs | 911 ++++++------------ src/backend_task/error.rs | 380 ++++++++ src/backend_task/mod.rs | 84 +- src/backend_task/platform_info.rs | 2 +- .../wallet/fetch_platform_address_balances.rs | 7 +- src/config.rs | 6 +- src/context/mod.rs | 61 +- src/context/wallet_lifecycle.rs | 51 +- src/mcp/error.rs | 26 +- src/mcp/resolve.rs | 9 + src/mcp/server.rs | 96 +- src/mcp/tools/network.rs | 193 +++- src/mcp/tools/wallet.rs | 6 +- src/ui/mod.rs | 145 +-- src/ui/network_chooser_screen.rs | 477 ++++----- src/ui/tokens/add_token_by_id_screen.rs | 6 +- src/ui/tokens/tokens_screen/mod.rs | 2 +- src/ui/tokens/tokens_screen/my_tokens.rs | 2 +- src/ui/wallets/wallets_screen/mod.rs | 17 +- 27 files changed, 2407 insertions(+), 1071 deletions(-) rename docs/{EXPOSING_BACKEND_TASKS.md => MCP_TOOL_DEVELOPMENT.md} (94%) create mode 100644 docs/ai-design/2026-03-30-dapi-discovery-button/ux-spec.md create mode 100644 docs/ai-design/2026-03-30-dapi-discovery-button/wireframe.html diff --git a/CLAUDE.md b/CLAUDE.md index dedc9ef40..ed6bea124 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,7 +115,7 @@ User-facing error messages (shown in `MessageBanner` via `Display`) must follow - **Backend dispatch**: tools reuse the app's `BackendTask` system via `dispatch::dispatch_task()` — creates a throwaway channel, calls `app_context.run_backend_task()`. - **Schema quirk**: `schemars` v1 derives bare `true` for `serde_json::Value` fields — some MCP clients reject this. Use `#[schemars(transform)]` to override. - **Error type**: `McpToolError` enum (InvalidParam, WalletNotFound, SpvSyncFailed, TaskFailed, Internal) converts to `rmcp::ErrorData` via `From`. -- **Docs**: `docs/MCP.md` (server config, tool reference), `docs/CLI.md` (usage, examples), `docs/EXPOSING_BACKEND_TASKS.md` (checklist for adding new MCP tools). +- **Docs**: `docs/MCP.md` (server config, tool reference), `docs/CLI.md` (usage, examples), `docs/MCP_TOOL_DEVELOPMENT.md` (checklist for adding new MCP tools). ### Key Dependencies diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4579b8409..a00f17f4d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -76,7 +76,7 @@ The default `cargo build` produces only the `dash-evo-tool` GUI binary. Optional ### Adding MCP tools -To expose a `BackendTask` as a new MCP/CLI tool, follow the step-by-step checklist in [docs/EXPOSING_BACKEND_TASKS.md](docs/EXPOSING_BACKEND_TASKS.md). It covers architecture rules, the standard invocation pattern, registration, and common pitfalls. +To expose a `BackendTask` as a new MCP/CLI tool, follow the step-by-step checklist in [docs/MCP_TOOL_DEVELOPMENT.md](docs/MCP_TOOL_DEVELOPMENT.md). It covers architecture rules, the standard invocation pattern, registration, and common pitfalls. ## Code quality diff --git a/Cargo.lock b/Cargo.lock index f3c36a9cc..1a5ace380 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1848,7 +1848,7 @@ dependencies = [ [[package]] name = "dapi-grpc" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" +source = "git+https://github.com/dashpay/platform?rev=94cefb30d9d8ad84b1d45e0a152341a2425f920b#94cefb30d9d8ad84b1d45e0a152341a2425f920b" dependencies = [ "dash-platform-macros", "futures-core", @@ -1950,7 +1950,7 @@ dependencies = [ [[package]] name = "dash-context-provider" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" +source = "git+https://github.com/dashpay/platform?rev=94cefb30d9d8ad84b1d45e0a152341a2425f920b#94cefb30d9d8ad84b1d45e0a152341a2425f920b" dependencies = [ "dpp", "drive", @@ -2039,7 +2039,7 @@ dependencies = [ [[package]] name = "dash-platform-macros" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" +source = "git+https://github.com/dashpay/platform?rev=94cefb30d9d8ad84b1d45e0a152341a2425f920b#94cefb30d9d8ad84b1d45e0a152341a2425f920b" dependencies = [ "heck", "quote", @@ -2049,7 +2049,7 @@ dependencies = [ [[package]] name = "dash-sdk" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" +source = "git+https://github.com/dashpay/platform?rev=94cefb30d9d8ad84b1d45e0a152341a2425f920b#94cefb30d9d8ad84b1d45e0a152341a2425f920b" dependencies = [ "arc-swap", "async-trait", @@ -2085,7 +2085,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=f92f114b83f6e442af8290611a10f2246ee58d3a#f92f114b83f6e442af8290611a10f2246ee58d3a" dependencies = [ "anyhow", "async-trait", @@ -2118,7 +2118,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=f92f114b83f6e442af8290611a10f2246ee58d3a#f92f114b83f6e442af8290611a10f2246ee58d3a" dependencies = [ "anyhow", "base64-compat", @@ -2143,12 +2143,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=f92f114b83f6e442af8290611a10f2246ee58d3a#f92f114b83f6e442af8290611a10f2246ee58d3a" [[package]] name = "dashcore-rpc" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=f92f114b83f6e442af8290611a10f2246ee58d3a#f92f114b83f6e442af8290611a10f2246ee58d3a" dependencies = [ "dashcore-rpc-json", "hex", @@ -2161,7 +2161,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=f92f114b83f6e442af8290611a10f2246ee58d3a#f92f114b83f6e442af8290611a10f2246ee58d3a" dependencies = [ "bincode 2.0.1", "dashcore", @@ -2176,7 +2176,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=f92f114b83f6e442af8290611a10f2246ee58d3a#f92f114b83f6e442af8290611a10f2246ee58d3a" dependencies = [ "bincode 2.0.1", "dashcore-private", @@ -2201,7 +2201,7 @@ dependencies = [ [[package]] name = "dashpay-contract" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" +source = "git+https://github.com/dashpay/platform?rev=94cefb30d9d8ad84b1d45e0a152341a2425f920b#94cefb30d9d8ad84b1d45e0a152341a2425f920b" dependencies = [ "platform-value", "platform-version", @@ -2212,7 +2212,7 @@ dependencies = [ [[package]] name = "data-contracts" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" +source = "git+https://github.com/dashpay/platform?rev=94cefb30d9d8ad84b1d45e0a152341a2425f920b#94cefb30d9d8ad84b1d45e0a152341a2425f920b" dependencies = [ "dashpay-contract", "dpns-contract", @@ -2465,7 +2465,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "dpns-contract" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" +source = "git+https://github.com/dashpay/platform?rev=94cefb30d9d8ad84b1d45e0a152341a2425f920b#94cefb30d9d8ad84b1d45e0a152341a2425f920b" dependencies = [ "platform-value", "platform-version", @@ -2476,7 +2476,7 @@ dependencies = [ [[package]] name = "dpp" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" +source = "git+https://github.com/dashpay/platform?rev=94cefb30d9d8ad84b1d45e0a152341a2425f920b#94cefb30d9d8ad84b1d45e0a152341a2425f920b" dependencies = [ "anyhow", "async-trait", @@ -2526,7 +2526,7 @@ dependencies = [ [[package]] name = "dpp-json-convertible-derive" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" +source = "git+https://github.com/dashpay/platform?rev=94cefb30d9d8ad84b1d45e0a152341a2425f920b#94cefb30d9d8ad84b1d45e0a152341a2425f920b" dependencies = [ "proc-macro2", "quote", @@ -2536,7 +2536,7 @@ dependencies = [ [[package]] name = "drive" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" +source = "git+https://github.com/dashpay/platform?rev=94cefb30d9d8ad84b1d45e0a152341a2425f920b#94cefb30d9d8ad84b1d45e0a152341a2425f920b" dependencies = [ "bincode 2.0.1", "byteorder", @@ -2561,7 +2561,7 @@ dependencies = [ [[package]] name = "drive-proof-verifier" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" +source = "git+https://github.com/dashpay/platform?rev=94cefb30d9d8ad84b1d45e0a152341a2425f920b#94cefb30d9d8ad84b1d45e0a152341a2425f920b" dependencies = [ "bincode 2.0.1", "dapi-grpc", @@ -3151,7 +3151,7 @@ dependencies = [ [[package]] name = "feature-flags-contract" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" +source = "git+https://github.com/dashpay/platform?rev=94cefb30d9d8ad84b1d45e0a152341a2425f920b#94cefb30d9d8ad84b1d45e0a152341a2425f920b" dependencies = [ "platform-value", "platform-version", @@ -4895,7 +4895,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=f92f114b83f6e442af8290611a10f2246ee58d3a#f92f114b83f6e442af8290611a10f2246ee58d3a" dependencies = [ "async-trait", "base58ck", @@ -4917,7 +4917,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=f92f114b83f6e442af8290611a10f2246ee58d3a#f92f114b83f6e442af8290611a10f2246ee58d3a" dependencies = [ "async-trait", "dashcore", @@ -4930,7 +4930,7 @@ dependencies = [ [[package]] name = "keyword-search-contract" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" +source = "git+https://github.com/dashpay/platform?rev=94cefb30d9d8ad84b1d45e0a152341a2425f920b#94cefb30d9d8ad84b1d45e0a152341a2425f920b" dependencies = [ "platform-value", "platform-version", @@ -5136,7 +5136,7 @@ dependencies = [ [[package]] name = "masternode-reward-shares-contract" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" +source = "git+https://github.com/dashpay/platform?rev=94cefb30d9d8ad84b1d45e0a152341a2425f920b#94cefb30d9d8ad84b1d45e0a152341a2425f920b" dependencies = [ "platform-value", "platform-version", @@ -6284,7 +6284,7 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "platform-encryption" version = "2.1.1" -source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" +source = "git+https://github.com/dashpay/platform?rev=94cefb30d9d8ad84b1d45e0a152341a2425f920b#94cefb30d9d8ad84b1d45e0a152341a2425f920b" dependencies = [ "aes", "cbc", @@ -6295,7 +6295,7 @@ dependencies = [ [[package]] name = "platform-serialization" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" +source = "git+https://github.com/dashpay/platform?rev=94cefb30d9d8ad84b1d45e0a152341a2425f920b#94cefb30d9d8ad84b1d45e0a152341a2425f920b" dependencies = [ "bincode 2.0.1", "platform-version", @@ -6304,7 +6304,7 @@ dependencies = [ [[package]] name = "platform-serialization-derive" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" +source = "git+https://github.com/dashpay/platform?rev=94cefb30d9d8ad84b1d45e0a152341a2425f920b#94cefb30d9d8ad84b1d45e0a152341a2425f920b" dependencies = [ "proc-macro2", "quote", @@ -6315,7 +6315,7 @@ dependencies = [ [[package]] name = "platform-value" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" +source = "git+https://github.com/dashpay/platform?rev=94cefb30d9d8ad84b1d45e0a152341a2425f920b#94cefb30d9d8ad84b1d45e0a152341a2425f920b" dependencies = [ "base64 0.22.1", "bincode 2.0.1", @@ -6335,7 +6335,7 @@ dependencies = [ [[package]] name = "platform-version" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" +source = "git+https://github.com/dashpay/platform?rev=94cefb30d9d8ad84b1d45e0a152341a2425f920b#94cefb30d9d8ad84b1d45e0a152341a2425f920b" dependencies = [ "bincode 2.0.1", "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", @@ -6346,7 +6346,7 @@ dependencies = [ [[package]] name = "platform-versioning" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" +source = "git+https://github.com/dashpay/platform?rev=94cefb30d9d8ad84b1d45e0a152341a2425f920b#94cefb30d9d8ad84b1d45e0a152341a2425f920b" dependencies = [ "proc-macro2", "quote", @@ -7216,7 +7216,7 @@ dependencies = [ [[package]] name = "rs-dapi-client" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" +source = "git+https://github.com/dashpay/platform?rev=94cefb30d9d8ad84b1d45e0a152341a2425f920b#94cefb30d9d8ad84b1d45e0a152341a2425f920b" dependencies = [ "backon", "chrono", @@ -7242,7 +7242,7 @@ dependencies = [ [[package]] name = "rs-sdk-trusted-context-provider" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" +source = "git+https://github.com/dashpay/platform?rev=94cefb30d9d8ad84b1d45e0a152341a2425f920b#94cefb30d9d8ad84b1d45e0a152341a2425f920b" dependencies = [ "arc-swap", "dash-context-provider", @@ -8471,7 +8471,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "token-history-contract" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" +source = "git+https://github.com/dashpay/platform?rev=94cefb30d9d8ad84b1d45e0a152341a2425f920b#94cefb30d9d8ad84b1d45e0a152341a2425f920b" dependencies = [ "platform-value", "platform-version", @@ -9270,7 +9270,7 @@ dependencies = [ [[package]] name = "wallet-utils-contract" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" +source = "git+https://github.com/dashpay/platform?rev=94cefb30d9d8ad84b1d45e0a152341a2425f920b#94cefb30d9d8ad84b1d45e0a152341a2425f920b" dependencies = [ "platform-value", "platform-version", @@ -10661,7 +10661,7 @@ dependencies = [ [[package]] name = "withdrawals-contract" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" +source = "git+https://github.com/dashpay/platform?rev=94cefb30d9d8ad84b1d45e0a152341a2425f920b#94cefb30d9d8ad84b1d45e0a152341a2425f920b" dependencies = [ "num_enum 0.5.11", "platform-value", diff --git a/Cargo.toml b/Cargo.toml index f2d0d580a..42903f1fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ qrcode = "0.14.1" nix = { version = "0.31.1", features = ["signal"] } eframe = { version = "0.33.3", features = ["persistence", "wgpu"] } base64 = "0.22.1" -dash-sdk = { git = "https://github.com/dashpay/platform", rev = "a10190399d7033e7e56e3f756411e9a5dab87829", features = [ +dash-sdk = { git = "https://github.com/dashpay/platform", rev = "94cefb30d9d8ad84b1d45e0a152341a2425f920b", features = [ "core_key_wallet", "core_key_wallet_manager", "core_bincode", @@ -28,7 +28,7 @@ dash-sdk = { git = "https://github.com/dashpay/platform", rev = "a10190399d7033e "core_spv", "shielded", ] } -rs-sdk-trusted-context-provider = { git = "https://github.com/dashpay/platform", rev = "a10190399d7033e7e56e3f756411e9a5dab87829" } +rs-sdk-trusted-context-provider = { git = "https://github.com/dashpay/platform", rev = "94cefb30d9d8ad84b1d45e0a152341a2425f920b" } zip32 = "0.2.0" grovestark = { git = "https://www.github.com/dashpay/grovestark", rev = "5b9e289cca54c79b1305d5f4f40bf1148f1eb0e3" } rayon = "1.8" diff --git a/docs/MCP.md b/docs/MCP.md index 939da36dd..d46b96619 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -68,6 +68,8 @@ Set these in the app's `.env` file (see `.env.example`) or as environment variab | Tool | Parameters | det-cli command | Description | |---|---|---|---| | `network_info` | — | `det-cli network-info` | Show active network and available configured networks | +| `network_reinit_sdk` | `network` | `det-cli network-reinit-sdk` | Rebuild Core RPC client and Platform SDK with current config (use after changing credentials) | +| `network_switch` | `network` | `det-cli network-switch` | Switch the active network (creates context if needed, may take a few seconds) | | `core_wallets_list` | `network`? | `det-cli core-wallets-list` | List wallets loaded in the app (alias + seed hash) | | `core_address_create` | `wallet_id`, `network`? | `det-cli core-address-create` | Generate a new receive address for a wallet | | `core_balances_get` | `wallet_id`, `network`? | `det-cli core-balances-get` | Show wallet balances (total, confirmed, unconfirmed) in duffs | @@ -88,6 +90,12 @@ Set these in the app's `.env` file (see `.env.example`) or as environment variab Parameters marked `?` are optional. The `det-cli` column shows the equivalent CLI command (underscores become hyphens). +### SPV requirements + +All wallet-facing tools wait for SPV to fully sync before executing. This includes both core-chain tools (`core_address_create`, `core_balances_get`, `core_funds_send`) and platform tools (`platform_addresses_list`, `identity_credits_topup`, `shielded_shield_from_core`). Even DAPI-only operations need SPV because the SDK verifies DAPI proofs against quorum and masternode list data from the synced chain. When another DET instance is already running, SPV falls back to a temporary directory and must sync from scratch. + +Only metadata tools that make no network calls (`core_wallets_list`, `network_info`, `tool_describe`) skip the SPV gate. + ## CLI interface (det-cli) `det-cli` is the command-line interface for interacting with MCP tools. It can operate in two modes: diff --git a/docs/EXPOSING_BACKEND_TASKS.md b/docs/MCP_TOOL_DEVELOPMENT.md similarity index 94% rename from docs/EXPOSING_BACKEND_TASKS.md rename to docs/MCP_TOOL_DEVELOPMENT.md index c41243036..a53676b44 100644 --- a/docs/EXPOSING_BACKEND_TASKS.md +++ b/docs/MCP_TOOL_DEVELOPMENT.md @@ -74,7 +74,7 @@ impl AsyncTool for MyNewTool { // 3. Resolve wallet if needed let seed_hash = resolve::wallet(&ctx, ¶m.wallet_id)?; - // 4. Wait for SPV sync if tool needs wallet/chain data + // 4. Wait for SPV sync (see SPV gate rule below) resolve::ensure_spv_synced(&ctx).await?; // 5. Build and dispatch the backend task @@ -97,7 +97,7 @@ impl AsyncTool for MyNewTool { - Skip `verify_network` only for `network_info` and `tool_describe`. - For destructive tools (`read_only: false`), the `network` parameter **must be required** (not optional with `#[serde(default)]`). Use `resolve::require_network()` instead of `resolve::verify_network()` to prevent accidental cross-network operations that could spend funds on the wrong network. - Skip wallet resolution if the tool doesn't operate on a wallet. -- Skip `ensure_spv_synced` if the tool reads only from the database or Platform SDK (no SPV-dependent data). For tools that only dispatch Platform state transitions (not Core UTXO spends), add an `// INTENTIONAL: no SPV sync needed` comment explaining why. +- **SPV gate rule**: Call `ensure_spv_synced` for **all wallet-facing tools** — both core-chain and platform/DAPI. The SDK verifies DAPI proofs against quorum and masternode list data from the synced SPV chain, so even platform-only queries fail without it. Skip only for metadata tools that make no network calls (`core_wallets_list`, `network_info`, `tool_describe`). ### 6. Register in `tool_router()` diff --git a/docs/ai-design/2026-03-30-dapi-discovery-button/ux-spec.md b/docs/ai-design/2026-03-30-dapi-discovery-button/ux-spec.md new file mode 100644 index 000000000..590c3de17 --- /dev/null +++ b/docs/ai-design/2026-03-30-dapi-discovery-button/ux-spec.md @@ -0,0 +1,396 @@ +# UX Specification: Manual DAPI Node Discovery Button + +**Date**: 2026-03-30 +**Feature**: User-triggered node address discovery in Network Settings +**Status**: Draft + +--- + +## 1. Problem Statement + +Dash Evo Tool connects to the Dash Platform network through DAPI nodes (masternodes). Currently, node addresses are configured in a `.env` file and, for Mainnet/Testnet, discovered automatically at startup from a DCG-operated HTTPS endpoint. The app is moving to a manual, user-triggered discovery model where: + +- No automatic discovery at startup -- the app always uses addresses from its config +- A button in the Network Settings screen lets users fetch current node addresses on demand +- Users understand they are fetching from a centralized service (DCG), not from the blockchain itself + +## 2. Personas and Walkthrough + +### Alex (Everyday User) + +Alex opens Network Settings because the app said it could not connect to the network. Alex sees the Connection Settings card and notices a message near the empty node address area: "No node addresses configured." Next to it is a clearly labeled button. Alex clicks it, sees a brief spinner, and the field fills with addresses. Alex clicks Save and the app connects. Alex never needs to understand what DAPI means or where the addresses came from. + +**Key need**: A single clear action that fixes the "can't connect" problem. No jargon. + +### Priya (Power User) + +Priya opens Network Settings to refresh her node list after a network upgrade. She sees the "Fetch Node List" button and the info icon next to it. She hovers the info icon and reads that addresses come from a DCG-operated service. She clicks the button. A confirmation dialog appears because she already has addresses configured -- it tells her the current count and the count about to be fetched. She confirms, reviews the new addresses in the text field, and saves. + +**Key need**: Transparency about what is happening, confirmation before overwriting, ability to review and edit the result. + +### Jordan (Platform Developer) + +Jordan is on a Devnet tab. The Fetch Node List button is not visible -- Jordan knows devnet addresses must be entered manually. On Testnet, Jordan uses the button occasionally but is just as likely to paste addresses directly. Jordan appreciates that the button does not auto-save -- it populates the field and lets Jordan edit before committing. + +**Key need**: Button stays out of the way on devnets. Does not auto-save; just populates. + +## 3. Design + +### 3.1 Placement + +The discovery button lives in a new **"Node Addresses"** section within the existing **Connection Settings** card, placed immediately below the Network selector row (and SPV warning, if shown) and above the Core RPC Password section. This section is visible for **all networks** but the button is only available for Mainnet and Testnet. + +``` +Connection Settings Card ++-------------------------------------------------------+ +| Connection Type: [SPV Client v] (dev mode only) | +| Network: [Mainnet v] | +| | +| --- Node Addresses --- | +| [multiline text field with current DAPI addresses] | +| [Fetch Node List] (i) | +| ^secondary btn ^info icon with tooltip | +| | +| --- Core RPC Password --- (RPC mode only) | +| [password field] [Save] | ++-------------------------------------------------------+ +``` + +**Layout details:** +- Section label: "Node Addresses" -- rendered as a bold subheading (`RichText::new(...).strong().color(text_primary)`) +- Text field: multiline `TextEdit` showing the current `dapi_addresses` value (comma-separated URLs). Editable. Approximately 3-4 lines tall. Uses `styled_text_edit_multiline()` with standard input strokes. +- Below the text field, a horizontal row containing the button and info icon +- Spacing follows existing patterns: `add_space(12.0)` before section, `add_space(8.0)` between label and field, `add_space(8.0)` between field and button row + +### 3.2 Button Design + +| Property | Value | +|----------|-------| +| **Label** | "Fetch Node List" | +| **Variant** | Secondary (outlined, `StyledButton` `ButtonVariant::Secondary`) | +| **Size** | Medium | +| **Icon** | None (egui icon support is limited; keep it text-only) | +| **Min width** | 160px | + +**Rationale for "Fetch Node List"**: +- Avoids "DAPI" jargon -- Alex does not know what DAPI is +- "Fetch" communicates a network operation (important for trust/timing expectations) +- "Node List" is more concrete than "Discover Nodes" -- it describes what you get +- Secondary variant ensures it does not compete visually with the primary "Save" button in the password section + +**Visibility rules:** +- **Mainnet, Testnet**: Button visible and enabled +- **Devnet, Regtest**: Button hidden. In its place, a small caption: "Enter node addresses manually for this network." + +### 3.3 Info Icon and Trust Disclosure + +To the right of the button, render a small info label `(i)` with an `info_tooltip()` (uses the Help cursor per UX design patterns). + +**Tooltip text:** + +> Fetches the current list of available nodes from a service operated by Dash Core Group (DCG). This is a convenience service over HTTPS -- it does not access the blockchain directly. Platform proofs are verified independently, so incorrect node addresses cannot forge data, but they could prevent the app from connecting. + +**Rationale**: Progressive disclosure. Alex never reads it. Priya hovers once, understands the trust model, and is satisfied. Jordan already knows but appreciates the precision. + +### 3.4 Empty State + +When the text field is empty AND the current network is Mainnet or Testnet, show a hint message below the text field (above the button row): + +``` +[text field -- empty, showing placeholder "No node addresses configured"] + + Use "Fetch Node List" to get the current addresses, or enter them manually. + +[Fetch Node List] (i) +``` + +The hint text is rendered in `DashColors::text_secondary(dark_mode)`, `Typography::SCALE_SM`, italics. It disappears once the field has content. + +For Devnet/Regtest with an empty field: + +``` +[text field -- empty, showing placeholder "No node addresses configured"] + + Enter node addresses for this network (comma-separated URLs). +``` + +### 3.5 Loading State + +When the user clicks "Fetch Node List": + +1. Button text changes to "Fetching..." with a spinner (`ui.spinner()`) to the left of the button text +2. Button is disabled (prevents double-submit per UX patterns) +3. Text field remains visible and read-only during the fetch (not disabled -- just non-editable, so the user can see existing content) +4. Expected duration is 1-10 seconds per the discovery module's 10-second timeout + +**Implementation note**: This should be dispatched as a `BackendTask` to avoid blocking the UI thread. The screen stores a `discovery_in_progress: bool` flag. The discovery result arrives through the standard `display_task_result()` path. + +### 3.6 Success State (No Existing Addresses) + +When the field was empty before the fetch: + +1. Text field is populated with the fetched addresses (comma-separated) +2. Button returns to default state +3. A success banner appears: "Found {count} node addresses. Review them below and save your settings." +4. The field is **not** auto-saved -- the user must click the existing Save mechanism (or the addresses are saved when the network config is saved) + +**Important**: The fetched addresses populate the field but do not persist until the user explicitly saves. This gives all personas a chance to review and edit. + +### 3.7 Success State (Existing Addresses -- Confirmation Dialog) + +When the field already contains addresses and the user clicks "Fetch Node List": + +1. A `ConfirmationDialog` appears before the fetch begins +2. Dialog content: + +``` ++---------------------------------------------------+ +| Update Node Addresses? | +| | +| This will replace your current node addresses | +| with a fresh list fetched from the Dash network | +| service. | +| | +| You currently have {N} addresses configured. | +| You can review and edit the new list before | +| saving. | +| | +| [Cancel] [Fetch] | ++---------------------------------------------------+ +``` + +| Dialog property | Value | +|-----------------|-------| +| Title | "Update Node Addresses?" | +| Confirm label | "Fetch" | +| Cancel label | "Cancel" | +| Danger mode | No (this is not destructive -- old addresses are only replaced in the field, not saved) | +| Escape/X | Cancels | + +**After confirmation**, the fetch proceeds and the field is populated with new addresses. The old addresses are replaced in the field but not persisted until Save. + +### 3.8 Error State + +When the fetch fails: + +1. Button returns to default state ("Fetch Node List", enabled) +2. An error banner appears via `MessageBanner::set_global()`: + - **Timeout**: "Node list fetch timed out. Check your internet connection and try again." + - **Network error**: "Could not fetch the node list. Check your internet connection and try again." + - **No results**: "No available nodes were found. The network may be temporarily unavailable -- try again later." + - **Other**: "Could not fetch the node list. Try again, or enter node addresses manually." +3. Existing addresses in the field are **not** modified on error + +These messages align with the existing `DapiDiscoveryError` variants in `src/dapi_discovery.rs`. + +### 3.9 Interaction with Save + +The node addresses field needs a Save mechanism. Two options: + +**Recommended approach**: Add the node addresses field to the existing config save flow. When the Core RPC Password "Save" button is clicked (or a new dedicated "Save" is added for this section), persist the current contents of the node addresses field to the `.env` config file using `Config::update_config_for_network()`. + +If the Node Addresses section is above the Core RPC Password section, adding a small "Save" button on the same row as the Fetch button keeps the interaction local: + +``` +[Fetch Node List] (i) [Save] +``` + +The Save button here: +- Uses `StyledButton` Secondary variant, same size as Fetch +- Saves the current text field content to the config file +- Shows success/error via `MessageBanner` +- Is always enabled (even if the field has not changed -- simpler, and egui does not trivially track dirty state) + +### 3.10 State Diagram + +``` + +------------------+ + | Default State | + | Field: current | + | Button: enabled | + +--------+---------+ + | + User clicks "Fetch Node List" + | + +--------v---------+ + yes | Field has | no + +--------+ existing addrs? +--------+ + | +------------------+ | + v v + +--------+---------+ +-----------+--------+ + | Confirmation | Cancel | Fetching State | + | Dialog shown +---+ | Button: disabled | + +--------+---------+ | | Spinner shown | + | | +---------+----------+ + Confirm | | + | | +--------v---------+ + v | yes | Fetch result? | no + +--------+---------+ | +-----+ +-----+ + | Fetching State | | | +------------------+ | + | Button: disabled | | v v + | Spinner shown | | +-+-------------+ +-----------+-+ + +--------+---------+ | | Success | | Error | + | | | Field updated | | Field stays | + | | | Banner: info | | Banner: err | + +--------v---------+ | +------+--------+ +------+------+ + | Fetch result? | | | | + +--+------------+--+ | v v + yes no | +----+--------------------+----+ + | | | | Default State | + v v +-->| (user can now Save) | + Success Error +------------------------------+ +``` + +## 4. Component Specification + +### 4.1 Node Address Text Field + +``` +Component: NodeAddressField +Purpose: Display and edit the comma-separated list of DAPI node URLs +Type: multiline TextEdit (styled_text_edit_multiline) +States: + - default: editable, shows current addresses + - empty: shows placeholder "No node addresses configured" + - read-only-during-fetch: text visible but not editable +Responsive: full available width, 3-4 lines height (approximately 80px) +Accessibility: + - Tab-focusable + - Placeholder text visible when empty +``` + +### 4.2 Fetch Node List Button + +``` +Component: StyledButton (Secondary variant) +Label: "Fetch Node List" +States: + - default: outlined secondary button, enabled + - hover: pointing hand cursor (automatic from StyledButton) + - loading: text changes to "Fetching...", disabled, spinner adjacent + - hidden: on Devnet/Regtest networks +Min width: 160px +Accessibility: + - Tab-focusable + - disabled_tooltip when loading: "Fetching node addresses..." +``` + +### 4.3 Info Icon + +``` +Component: label "(i)" with info_tooltip() +Purpose: Trust disclosure for the discovery service +States: + - default: subtle text in text_secondary color + - hover: Help cursor, tooltip shown +Accessibility: + - info_tooltip provides the help cursor automatically +``` + +### 4.4 Save Button (Node Addresses) + +``` +Component: StyledButton (Secondary variant) +Label: "Save" +Purpose: Persist node addresses to config file +States: + - default: enabled + - hover: pointing hand cursor +Placement: right-aligned on the button row +``` + +### 4.5 Confirmation Dialog + +``` +Component: ConfirmationDialog (existing pattern) +Title: "Update Node Addresses?" +Body: explains what will happen, shows current address count +Confirm label: "Fetch" +Cancel label: "Cancel" +Danger mode: false +Trigger: clicking Fetch when field already has content +``` + +## 5. Screen State Additions + +New fields on `NetworkChooserScreen`: + +```rust +/// Current text in the node addresses field per network +node_addresses_text: HashMap, + +/// Whether a discovery fetch is in progress +discovery_in_progress: bool, + +/// Confirmation dialog for overwriting existing addresses +discovery_confirm_dialog: Option, +``` + +On construction, `node_addresses_text` is populated from `Config::load_from()` for each network's `dapi_addresses` field. + +## 6. Backend Task + +A new `SystemTask` variant handles the async discovery: + +```rust +SystemTask::DiscoverDapiNodes { network: Network } +``` + +This calls the existing `try_discover_nodes()` from `src/dapi_discovery.rs` (the async variant, not the sync one with fallback). The result is returned as a new `BackendTaskSuccessResult` variant: + +```rust +BackendTaskSuccessResult::DapiNodesDiscovered { + network: Network, + addresses: Vec, +} +``` + +The screen's `display_task_result()` handler populates `node_addresses_text[network]` with the comma-separated result and shows the success banner. + +## 7. Accessibility + +| Requirement | Implementation | +|-------------|----------------| +| Keyboard navigation | All elements (text field, buttons, info icon) are Tab-focusable in layout order | +| Focus indicator | Standard egui focus ring (BORDER_WIDTH_THICK per UX patterns) | +| Tooltips | info_tooltip on (i) icon, disabled_tooltip on button during fetch | +| Screen readers | egui has limited a11y; no additional ARIA annotations possible | +| Color contrast | All text meets WCAG AA (inherited from DashColors theme system) | +| Click targets | Buttons use StyledButton which meets WCAG AA minimum targets | + +## 8. Responsive Behavior + +The Node Addresses section uses `ui.available_width()` for the text field (full width). The button row is horizontal with the Fetch button left-aligned and Save button right-aligned. On narrow windows, buttons wrap naturally via egui's horizontal layout (they will stack if space is insufficient, which is acceptable for this secondary feature). + +## 9. Edge Cases + +| Case | Behavior | +|------|----------| +| User clicks Fetch, then switches network tab during fetch | The result is tagged with the network it was fetched for. If the user has switched networks, the result populates `node_addresses_text[original_network]` silently. The banner still appears. | +| Fetch returns identical addresses to what was already configured | Treat as success. Field is updated (same content). Banner shows the count. | +| User edits the field manually, then clicks Fetch | Confirmation dialog appears (field has content). Fetch replaces the manual edits in the field. | +| User clicks Fetch, gets results, then clicks Fetch again without saving | Confirmation dialog appears (field has content from the first fetch). Second fetch replaces. | +| Config file is read-only or missing | Save button shows error banner: "Could not save settings. Check that the application folder is writable and retry." (matches existing `ConfigError::SaveError` pattern). | +| Discovery returns hundreds of addresses | All addresses populate the field. The multiline TextEdit scrolls. User can edit to trim if desired. | + +## 10. What This Spec Does NOT Cover + +- Automatic migration of old hardcoded addresses (handled separately in `config.rs`) +- Changes to the startup discovery flow (separate task) +- The `.env` file format or config parsing (existing code in `Config`) +- MCP/CLI exposure of discovery (out of scope for UI spec) + +## 11. Implementation Checklist + +- [ ] Add `node_addresses_text: HashMap` and related fields to `NetworkChooserScreen` +- [ ] Render the Node Addresses section in `render_network_table()` between Network selector and Core RPC Password +- [ ] Implement `StyledButton::secondary()` variant usage for Fetch and Save buttons +- [ ] Add `SystemTask::DiscoverDapiNodes` backend task variant +- [ ] Add `BackendTaskSuccessResult::DapiNodesDiscovered` result variant +- [ ] Wire `display_task_result()` to populate the text field on success +- [ ] Add `ConfirmationDialog` for overwrite confirmation +- [ ] Add Save button that persists `node_addresses_text` to config +- [ ] Hide Fetch button and show manual-entry hint on Devnet/Regtest +- [ ] Show empty-state guidance when no addresses are configured +- [ ] Test light and dark themes +- [ ] Verify keyboard navigation (Tab through field, Fetch, Save) diff --git a/docs/ai-design/2026-03-30-dapi-discovery-button/wireframe.html b/docs/ai-design/2026-03-30-dapi-discovery-button/wireframe.html new file mode 100644 index 000000000..e4395f443 --- /dev/null +++ b/docs/ai-design/2026-03-30-dapi-discovery-button/wireframe.html @@ -0,0 +1,515 @@ + + + + + +DAPI Discovery Button - Wireframe + + + +

DAPI Node Discovery Button

+

Network Settings Screen - Wireframe

+ +
+ + + +
+
Default (with addresses)
+
Empty State
+
Fetching
+
Success
+
Error
+
Devnet
+
Confirmation
+
+
+ + + + + + + + +
+
Connection Settings
+ + +
+
Network:
+
+ Mainnet +
+
+ + +
+ + + + + + + + + + + + + + + +
+
+ + + + + + + + (i) +
+ Fetches the current list of available nodes from a service operated by Dash Core Group (DCG). This is a convenience service over HTTPS -- it does not access the blockchain directly. Platform proofs are verified independently, so incorrect node addresses cannot forge data, but they could prevent the app from connecting. +
+
+
+ + +
+ + +
+ +
+ + +
+
+ + +
+
Connection Status
+
+
Core RPC: Connected
+
ZMQ: Connected
+
DAPI: 147/147 available
+
+
+ + +
+
+
Update Node Addresses?
+
+ This will replace your current node addresses with a fresh list fetched from the Dash network service. +

+ You currently have 8 addresses configured. You can review and edit the new list before saving. +
+
+ + +
+
+
+ + + + diff --git a/src/app.rs b/src/app.rs index 22e8c4a27..20dd770b0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -65,31 +65,84 @@ impl From> for TaskResult { } } +struct ThemeState { + preference: ThemeMode, + resolved: ThemeMode, + last_applied: Option, + last_checked: Instant, +} + +impl ThemeState { + fn new(preference: ThemeMode) -> Self { + Self { + resolved: crate::ui::theme::resolve_theme_mode(preference), + last_applied: None, + last_checked: Instant::now(), + preference, + } + } + + /// Polls the OS for system theme changes (throttled to every 2s) and + /// applies the theme if it changed. Returns `true` if the theme was applied. + fn poll_and_apply(&mut self, ctx: &egui::Context) -> bool { + if self.preference == ThemeMode::System { + let now = Instant::now(); + if now.duration_since(self.last_checked) >= Duration::from_secs(2) { + self.last_checked = now; + if let Some(detected) = crate::ui::theme::try_detect_system_theme() + && detected != self.resolved + { + self.resolved = detected; + } + } + } + if self.last_applied != Some(self.resolved) { + crate::ui::theme::apply_theme(ctx, self.resolved); + self.last_applied = Some(self.resolved); + true + } else { + false + } + } + + fn apply_new_preference(&mut self, ctx: &egui::Context, new_theme: ThemeMode) -> bool { + self.preference = new_theme; + let mut detection_failed = false; + self.resolved = if new_theme == ThemeMode::System { + match crate::ui::theme::try_detect_system_theme() { + Some(detected) => detected, + None => { + detection_failed = true; + self.resolved + } + } + } else { + new_theme + }; + self.last_checked = Instant::now(); + crate::ui::theme::apply_theme(ctx, self.resolved); + self.last_applied = Some(self.resolved); + detection_failed + } +} + pub struct AppState { pub main_screens: BTreeMap, pub selected_main_screen: RootScreenType, pub screen_stack: Vec, pub chosen_network: Network, pub connection_status: Arc, - pub mainnet_app_context: Arc, - pub testnet_app_context: Option>, - pub devnet_app_context: Option>, - pub local_app_context: Option>, - #[allow(dead_code)] // Kept alive for the lifetime of the app - pub mainnet_core_zmq_listener: Option, - #[allow(dead_code)] // Kept alive for the lifetime of the app - pub testnet_core_zmq_listener: Option, + pub network_contexts: BTreeMap>, + /// Network whose context is being created asynchronously. While `Some`, + /// the UI shows a progress banner and ignores further switch requests. + network_switch_pending: Option, #[allow(dead_code)] // Kept alive for the lifetime of the app - pub devnet_core_zmq_listener: Option, - #[allow(dead_code)] // Kept alive for the lifetime of the app - pub local_core_zmq_listener: Option, + zmq_listeners: BTreeMap, + core_message_sender: egui_mpsc::SenderSync<(ZMQMessage, Network)>, pub core_message_receiver: mpsc::Receiver<(ZMQMessage, Network)>, pub task_result_sender: egui_mpsc::SenderAsync, // Channel sender for sending task results pub task_result_receiver: tokiompsc::Receiver, // Channel receiver for receiving task results - pub theme_preference: ThemeMode, // Current theme preference - resolved_theme: ThemeMode, // Cached resolved theme (Light/Dark, never System) - last_applied_theme: Option, // Last theme passed to apply_theme; None = force on next frame - theme_last_checked: Instant, // Last time we polled the OS for system theme + theme: ThemeState, last_scheduled_vote_check: Instant, // Last time we checked if there are scheduled masternode votes to cast last_repaint_request: Instant, // Throttle periodic repaint scheduling to once per second pub subtasks: Arc, // Subtasks manager for graceful shutdown @@ -241,6 +294,8 @@ impl AppState { let subtasks = Arc::new(TaskManager::new()); let connection_status = Arc::new(ConnectionStatus::new()); + let saved_network = settings.network; + // Build a helper to create AppContext for a given network. let make_context = |network: Network| -> Option> { AppContext::new( @@ -254,11 +309,47 @@ impl AppState { ) }; - let mainnet_app_context = make_context(Network::Mainnet) - .ok_or("Failed to create AppContext for mainnet. Check your Dash configuration.")?; - let testnet_app_context = make_context(Network::Testnet); - let devnet_app_context = make_context(Network::Devnet); - let local_app_context = make_context(Network::Regtest); + // Only create the saved/active network eagerly; defer ALL others + // (including mainnet) until the user switches to them. This avoids + // DAPI discovery + SDK init for networks the user may never use. + // + // If the saved network fails (e.g., no DAPI addresses configured), + // try other networks before giving up. The user can fix the config + // via the "Fetch Node List" button in Network Settings. + let mut network_contexts = BTreeMap::new(); + let try_order = std::iter::once(saved_network).chain( + [ + Network::Mainnet, + Network::Testnet, + Network::Devnet, + Network::Regtest, + ] + .into_iter() + .filter(|n| *n != saved_network), + ); + for net in try_order { + if let Some(ctx) = make_context(net) { + network_contexts.insert(net, ctx); + break; + } + if net == saved_network { + tracing::warn!( + "Could not create context for saved network {:?}. \ + Check your node addresses. Trying other networks...", + saved_network + ); + } + } + if network_contexts.is_empty() { + return Err( + "No network could be initialized. Check that at least one network has \ + DAPI node addresses configured in your settings file. You can use the \ + \"Fetch Node List\" button in Network Settings to get addresses." + .into(), + ); + } + let chosen_network = *network_contexts.keys().next().unwrap(); + let active_context = network_contexts.get(&chosen_network).unwrap().clone(); // load fonts ctx.set_fonts(crate::bundled::fonts().expect("failed to load fonts")); @@ -275,199 +366,44 @@ impl AppState { ctx.enable_accesskit(); } - // create screens - let mut identities_screen = IdentitiesScreen::new(&mainnet_app_context); - let mut dpns_active_contests_screen = - DPNSScreen::new(&mainnet_app_context, DPNSSubscreen::Active); - let mut dpns_past_contests_screen = - DPNSScreen::new(&mainnet_app_context, DPNSSubscreen::Past); - let mut dpns_my_usernames_screen = - DPNSScreen::new(&mainnet_app_context, DPNSSubscreen::Owned); - let mut dpns_scheduled_votes_screen = - DPNSScreen::new(&mainnet_app_context, DPNSSubscreen::ScheduledVotes); - let mut transition_visualizer_screen = - TransitionVisualizerScreen::new(&mainnet_app_context); - let mut proof_visualizer_screen = ProofVisualizerScreen::new(&mainnet_app_context); - let mut document_visualizer_screen = DocumentVisualizerScreen::new(&mainnet_app_context); - let mut contract_visualizer_screen = ContractVisualizerScreen::new(&mainnet_app_context); - let mut proof_log_screen = ProofLogScreen::new(&mainnet_app_context); - let mut platform_info_screen = PlatformInfoScreen::new(&mainnet_app_context); - let mut address_balance_screen = AddressBalanceScreen::new(&mainnet_app_context); - let mut grovestark_screen = GroveSTARKScreen::new(&mainnet_app_context); - let mut document_query_screen = DocumentQueryScreen::new(&mainnet_app_context); - let mut tokens_balances_screen = - TokensScreen::new(&mainnet_app_context, TokensSubscreen::MyTokens); - let mut token_search_screen = - TokensScreen::new(&mainnet_app_context, TokensSubscreen::SearchTokens); - let mut token_creator_screen = - TokensScreen::new(&mainnet_app_context, TokensSubscreen::TokenCreator); - let mut contracts_dashpay_screen = - DashPayScreen::new(&mainnet_app_context, DashPaySubscreen::Profile); - - // Create DashPay screens - let mut dashpay_contacts_screen = - DashPayScreen::new(&mainnet_app_context, DashPaySubscreen::Contacts); - let mut dashpay_profile_screen = - DashPayScreen::new(&mainnet_app_context, DashPaySubscreen::Profile); - let mut dashpay_payments_screen = - DashPayScreen::new(&mainnet_app_context, DashPaySubscreen::Payments); - let mut dashpay_profile_search_screen = - ProfileSearchScreen::new(mainnet_app_context.clone()); - - let mut network_chooser_screen = NetworkChooserScreen::new( - &mainnet_app_context, - testnet_app_context.as_ref(), - devnet_app_context.as_ref(), - local_app_context.as_ref(), - Network::Mainnet, - overwrite_dash_conf, - ); - - let mut masternode_list_diff_screen = MasternodeListDiffScreen::new(&mainnet_app_context); - - let mut wallets_balances_screen = WalletsBalancesScreen::new(&mainnet_app_context); + // All screens are initialized with the active context (chosen_network). + // They will get the right context via change_context() on network switch. + let identities_screen = IdentitiesScreen::new(&active_context); + let dpns_active_contests_screen = DPNSScreen::new(&active_context, DPNSSubscreen::Active); + let dpns_past_contests_screen = DPNSScreen::new(&active_context, DPNSSubscreen::Past); + let dpns_my_usernames_screen = DPNSScreen::new(&active_context, DPNSSubscreen::Owned); + let dpns_scheduled_votes_screen = + DPNSScreen::new(&active_context, DPNSSubscreen::ScheduledVotes); + let transition_visualizer_screen = TransitionVisualizerScreen::new(&active_context); + let proof_visualizer_screen = ProofVisualizerScreen::new(&active_context); + let document_visualizer_screen = DocumentVisualizerScreen::new(&active_context); + let contract_visualizer_screen = ContractVisualizerScreen::new(&active_context); + let proof_log_screen = ProofLogScreen::new(&active_context); + let platform_info_screen = PlatformInfoScreen::new(&active_context); + let address_balance_screen = AddressBalanceScreen::new(&active_context); + let grovestark_screen = GroveSTARKScreen::new(&active_context); + let document_query_screen = DocumentQueryScreen::new(&active_context); + let tokens_balances_screen = TokensScreen::new(&active_context, TokensSubscreen::MyTokens); + let token_search_screen = TokensScreen::new(&active_context, TokensSubscreen::SearchTokens); + let token_creator_screen = + TokensScreen::new(&active_context, TokensSubscreen::TokenCreator); + let contracts_dashpay_screen = + DashPayScreen::new(&active_context, DashPaySubscreen::Profile); + let dashpay_contacts_screen = + DashPayScreen::new(&active_context, DashPaySubscreen::Contacts); + let dashpay_profile_screen = DashPayScreen::new(&active_context, DashPaySubscreen::Profile); + let dashpay_payments_screen = + DashPayScreen::new(&active_context, DashPaySubscreen::Payments); + let dashpay_profile_search_screen = ProfileSearchScreen::new(active_context.clone()); + + let network_chooser_screen = + NetworkChooserScreen::new(&network_contexts, saved_network, overwrite_dash_conf); + + let masternode_list_diff_screen = MasternodeListDiffScreen::new(&active_context); + + let wallets_balances_screen = WalletsBalancesScreen::new(&active_context); let selected_main_screen = settings.root_screen_type; - // Validate that the saved network has an available context. - // We fail fast instead of silently routing user actions to a different network. - let chosen_network = match settings.network { - Network::Mainnet => Network::Mainnet, - Network::Testnet => { - assert!( - testnet_app_context.is_some(), - "Saved network is Testnet but no Testnet AppContext is configured" - ); - Network::Testnet - } - Network::Devnet => { - assert!( - devnet_app_context.is_some(), - "Saved network is Devnet but no Devnet AppContext is configured" - ); - Network::Devnet - } - Network::Regtest => { - assert!( - local_app_context.is_some(), - "Saved network is Regtest but no Regtest AppContext is configured" - ); - Network::Regtest - } - unsupported_network => { - panic!( - "Saved network {:?} is unsupported. Refusing automatic fallback.", - unsupported_network - ); - } - }; - network_chooser_screen.current_network = chosen_network; - - if let (Network::Testnet, Some(testnet_app_context)) = - (chosen_network, testnet_app_context.as_ref()) - { - identities_screen = IdentitiesScreen::new(testnet_app_context); - dpns_active_contests_screen = - DPNSScreen::new(testnet_app_context, DPNSSubscreen::Active); - dpns_past_contests_screen = DPNSScreen::new(testnet_app_context, DPNSSubscreen::Past); - dpns_my_usernames_screen = DPNSScreen::new(testnet_app_context, DPNSSubscreen::Owned); - dpns_scheduled_votes_screen = - DPNSScreen::new(testnet_app_context, DPNSSubscreen::ScheduledVotes); - transition_visualizer_screen = TransitionVisualizerScreen::new(testnet_app_context); - proof_visualizer_screen = ProofVisualizerScreen::new(testnet_app_context); - document_visualizer_screen = DocumentVisualizerScreen::new(testnet_app_context); - contract_visualizer_screen = ContractVisualizerScreen::new(testnet_app_context); - document_query_screen = DocumentQueryScreen::new(testnet_app_context); - grovestark_screen = GroveSTARKScreen::new(testnet_app_context); - wallets_balances_screen = WalletsBalancesScreen::new(testnet_app_context); - proof_log_screen = ProofLogScreen::new(testnet_app_context); - platform_info_screen = PlatformInfoScreen::new(testnet_app_context); - address_balance_screen = AddressBalanceScreen::new(testnet_app_context); - masternode_list_diff_screen = MasternodeListDiffScreen::new(testnet_app_context); - contracts_dashpay_screen = - DashPayScreen::new(testnet_app_context, DashPaySubscreen::Profile); - tokens_balances_screen = - TokensScreen::new(testnet_app_context, TokensSubscreen::MyTokens); - token_search_screen = - TokensScreen::new(testnet_app_context, TokensSubscreen::SearchTokens); - token_creator_screen = - TokensScreen::new(testnet_app_context, TokensSubscreen::TokenCreator); - dashpay_contacts_screen = - DashPayScreen::new(testnet_app_context, DashPaySubscreen::Contacts); - dashpay_profile_screen = - DashPayScreen::new(testnet_app_context, DashPaySubscreen::Profile); - dashpay_payments_screen = - DashPayScreen::new(testnet_app_context, DashPaySubscreen::Payments); - dashpay_profile_search_screen = ProfileSearchScreen::new(testnet_app_context.clone()); - } else if let (Network::Devnet, Some(devnet_app_context)) = - (chosen_network, devnet_app_context.as_ref()) - { - identities_screen = IdentitiesScreen::new(devnet_app_context); - dpns_active_contests_screen = - DPNSScreen::new(devnet_app_context, DPNSSubscreen::Active); - dpns_past_contests_screen = DPNSScreen::new(devnet_app_context, DPNSSubscreen::Past); - dpns_my_usernames_screen = DPNSScreen::new(devnet_app_context, DPNSSubscreen::Owned); - dpns_scheduled_votes_screen = - DPNSScreen::new(devnet_app_context, DPNSSubscreen::ScheduledVotes); - transition_visualizer_screen = TransitionVisualizerScreen::new(devnet_app_context); - proof_visualizer_screen = ProofVisualizerScreen::new(devnet_app_context); - document_visualizer_screen = DocumentVisualizerScreen::new(devnet_app_context); - document_query_screen = DocumentQueryScreen::new(devnet_app_context); - masternode_list_diff_screen = MasternodeListDiffScreen::new(devnet_app_context); - contract_visualizer_screen = ContractVisualizerScreen::new(devnet_app_context); - grovestark_screen = GroveSTARKScreen::new(devnet_app_context); - wallets_balances_screen = WalletsBalancesScreen::new(devnet_app_context); - proof_log_screen = ProofLogScreen::new(devnet_app_context); - platform_info_screen = PlatformInfoScreen::new(devnet_app_context); - address_balance_screen = AddressBalanceScreen::new(devnet_app_context); - tokens_balances_screen = - TokensScreen::new(devnet_app_context, TokensSubscreen::MyTokens); - token_search_screen = - TokensScreen::new(devnet_app_context, TokensSubscreen::SearchTokens); - token_creator_screen = - TokensScreen::new(devnet_app_context, TokensSubscreen::TokenCreator); - dashpay_contacts_screen = - DashPayScreen::new(devnet_app_context, DashPaySubscreen::Contacts); - dashpay_profile_screen = - DashPayScreen::new(devnet_app_context, DashPaySubscreen::Profile); - dashpay_payments_screen = - DashPayScreen::new(devnet_app_context, DashPaySubscreen::Payments); - dashpay_profile_search_screen = ProfileSearchScreen::new(devnet_app_context.clone()); - } else if let (Network::Regtest, Some(local_app_context)) = - (chosen_network, local_app_context.as_ref()) - { - identities_screen = IdentitiesScreen::new(local_app_context); - dpns_active_contests_screen = DPNSScreen::new(local_app_context, DPNSSubscreen::Active); - dpns_past_contests_screen = DPNSScreen::new(local_app_context, DPNSSubscreen::Past); - dpns_my_usernames_screen = DPNSScreen::new(local_app_context, DPNSSubscreen::Owned); - dpns_scheduled_votes_screen = - DPNSScreen::new(local_app_context, DPNSSubscreen::ScheduledVotes); - transition_visualizer_screen = TransitionVisualizerScreen::new(local_app_context); - proof_visualizer_screen = ProofVisualizerScreen::new(local_app_context); - document_visualizer_screen = DocumentVisualizerScreen::new(local_app_context); - contract_visualizer_screen = ContractVisualizerScreen::new(local_app_context); - document_query_screen = DocumentQueryScreen::new(local_app_context); - grovestark_screen = GroveSTARKScreen::new(local_app_context); - wallets_balances_screen = WalletsBalancesScreen::new(local_app_context); - masternode_list_diff_screen = MasternodeListDiffScreen::new(local_app_context); - proof_log_screen = ProofLogScreen::new(local_app_context); - platform_info_screen = PlatformInfoScreen::new(local_app_context); - address_balance_screen = AddressBalanceScreen::new(local_app_context); - contracts_dashpay_screen = - DashPayScreen::new(local_app_context, DashPaySubscreen::Profile); - tokens_balances_screen = - TokensScreen::new(local_app_context, TokensSubscreen::MyTokens); - token_search_screen = - TokensScreen::new(local_app_context, TokensSubscreen::SearchTokens); - token_creator_screen = - TokensScreen::new(local_app_context, TokensSubscreen::TokenCreator); - dashpay_contacts_screen = - DashPayScreen::new(local_app_context, DashPaySubscreen::Contacts); - dashpay_profile_screen = - DashPayScreen::new(local_app_context, DashPaySubscreen::Profile); - dashpay_payments_screen = - DashPayScreen::new(local_app_context, DashPaySubscreen::Payments); - dashpay_profile_search_screen = ProfileSearchScreen::new(local_app_context.clone()); - } // // Create a channel with a buffer size of 32 (adjust as needed) let (task_result_sender, task_result_receiver) = @@ -477,161 +413,19 @@ impl AppState { let (core_message_sender, core_message_receiver) = mpsc::channel().with_egui_ctx(ctx.clone()); - let mainnet_core_zmq_endpoint = mainnet_app_context - .config - .read() - .unwrap() - .core_zmq_endpoint - .clone() - .unwrap_or_else(|| "tcp://127.0.0.1:23708".to_string()); - let mainnet_disable_zmq = mainnet_app_context - .get_settings() - .ok() - .flatten() - .map(|s| s.disable_zmq) - .unwrap_or(false); - let mainnet_core_zmq_listener = if !mainnet_disable_zmq { - match CoreZMQListener::spawn_listener( - Network::Mainnet, - &mainnet_core_zmq_endpoint, - core_message_sender.clone(), - Some(mainnet_app_context.sx_zmq_status.clone()), - ) { - Ok(listener) => Some(listener), - Err(e) => { - tracing::error!( - "Failed to create mainnet ZMQ listener: {}. ZMQ features will be unavailable for mainnet.", - e - ); - None - } - } - } else { - None - }; - - let testnet_tx_zmq_status_option = testnet_app_context - .as_ref() - .map(|context| context.sx_zmq_status.clone()); - - let testnet_core_zmq_endpoint = testnet_app_context - .as_ref() - .and_then(|ctx| ctx.config.read().unwrap().core_zmq_endpoint.clone()) - .unwrap_or_else(|| "tcp://127.0.0.1:23709".to_string()); - let testnet_disable_zmq = testnet_app_context - .as_ref() - .and_then(|ctx| ctx.get_settings().ok().flatten()) - .map(|s| s.disable_zmq) - .unwrap_or(false); - let testnet_core_zmq_listener = if !testnet_disable_zmq { - match CoreZMQListener::spawn_listener( - Network::Testnet, - &testnet_core_zmq_endpoint, - core_message_sender.clone(), - testnet_tx_zmq_status_option, - ) { - Ok(listener) => Some(listener), - Err(e) => { - tracing::error!( - "Failed to create testnet ZMQ listener: {}. ZMQ features will be unavailable for testnet.", - e - ); - None - } - } - } else { - None - }; - - let devnet_tx_zmq_status_option = devnet_app_context - .as_ref() - .map(|context| context.sx_zmq_status.clone()); - - let devnet_core_zmq_endpoint = devnet_app_context - .as_ref() - .and_then(|ctx| ctx.config.read().unwrap().core_zmq_endpoint.clone()) - .unwrap_or_else(|| "tcp://127.0.0.1:23710".to_string()); - let devnet_disable_zmq = devnet_app_context - .as_ref() - .and_then(|ctx| ctx.get_settings().ok().flatten()) - .map(|s| s.disable_zmq) - .unwrap_or(false); - let devnet_core_zmq_listener = if !devnet_disable_zmq { - match CoreZMQListener::spawn_listener( - Network::Devnet, - &devnet_core_zmq_endpoint, - core_message_sender.clone(), - devnet_tx_zmq_status_option, - ) { - Ok(listener) => Some(listener), - Err(e) => { - tracing::error!( - "Failed to create devnet ZMQ listener: {}. ZMQ features will be unavailable for devnet.", - e - ); - None - } - } - } else { - None - }; - - let local_tx_zmq_status_option = local_app_context - .as_ref() - .map(|context| context.sx_zmq_status.clone()); - - let local_core_zmq_endpoint = local_app_context - .as_ref() - .and_then(|ctx| ctx.config.read().unwrap().core_zmq_endpoint.clone()) - .unwrap_or_else(|| "tcp://127.0.0.1:20302".to_string()); - let local_disable_zmq = local_app_context - .as_ref() - .and_then(|ctx| ctx.get_settings().ok().flatten()) - .map(|s| s.disable_zmq) - .unwrap_or(false); - let local_core_zmq_listener = if !local_disable_zmq { - match CoreZMQListener::spawn_listener( - Network::Regtest, - &local_core_zmq_endpoint, - core_message_sender, - local_tx_zmq_status_option, - ) { - Ok(listener) => Some(listener), - Err(e) => { - tracing::error!( - "Failed to create local ZMQ listener: {}. ZMQ features will be unavailable for local/regtest.", - e - ); - None - } - } - } else { - None - }; + let zmq_listeners: BTreeMap = network_contexts + .iter() + .filter_map(|(&network, ctx)| { + Self::spawn_zmq_listener(ctx, network, &core_message_sender) + .map(|listener| (network, listener)) + }) + .collect(); // MCP server (feature-gated, opt-in via MCP_API_KEY env var) #[cfg(feature = "mcp")] let mcp_app_context = { if let Some(mcp_config) = crate::mcp::McpConfig::from_env() { - let initial_ctx = match chosen_network { - Network::Mainnet => mainnet_app_context.clone(), - Network::Testnet => testnet_app_context - .as_ref() - .expect("MCP: chosen network is Testnet but no Testnet AppContext") - .clone(), - Network::Devnet => devnet_app_context - .as_ref() - .expect("MCP: chosen network is Devnet but no Devnet AppContext") - .clone(), - Network::Regtest => local_app_context - .as_ref() - .expect("MCP: chosen network is Regtest but no Regtest AppContext") - .clone(), - unsupported => panic!( - "MCP: unsupported network {:?} for initial context", - unsupported - ), - }; + let initial_ctx = active_context.clone(); let mcp_ctx = Arc::new(arc_swap::ArcSwap::new(initial_ctx)); let ctx_for_server = mcp_ctx.clone(); let cancel = subtasks.cancellation_token.clone(); @@ -762,21 +556,14 @@ impl AppState { screen_stack: vec![], chosen_network, connection_status, - mainnet_app_context, - testnet_app_context, - devnet_app_context, - local_app_context, - mainnet_core_zmq_listener, - testnet_core_zmq_listener, - devnet_core_zmq_listener, - local_core_zmq_listener, + network_contexts, + network_switch_pending: None, + zmq_listeners, + core_message_sender, core_message_receiver, task_result_sender, task_result_receiver, - resolved_theme: crate::ui::theme::resolve_theme_mode(theme_preference), - last_applied_theme: None, - theme_last_checked: Instant::now(), - theme_preference, + theme: ThemeState::new(theme_preference), last_scheduled_vote_check: Instant::now(), last_repaint_request: Instant::now(), subtasks, @@ -793,27 +580,12 @@ impl AppState { mcp_app_context, }; - // Initialize welcome screen if needed (after mainnet_app_context is owned by the struct) + // Initialize welcome screen if needed (uses whichever context is active) if app_state.show_welcome_screen { app_state.welcome_screen = - Some(WelcomeScreen::new(app_state.mainnet_app_context.clone())); + Some(WelcomeScreen::new(app_state.current_app_context().clone())); } else { - // Auto-start SPV sync if onboarding is completed, backend mode is SPV, auto-start is enabled, - // and developer mode is enabled. - // TODO: SPV auto-start is gated behind developer mode while SPV is in development. - // Remove the is_developer_mode() check once SPV is production-ready. - let current_context = app_state.current_app_context(); - let auto_start_spv = db.get_auto_start_spv().unwrap_or(false); - if auto_start_spv - && current_context.is_developer_mode() - && current_context.core_backend_mode() == crate::spv::CoreBackendMode::Spv - { - if let Err(e) = current_context.start_spv() { - tracing::warn!("Failed to auto-start SPV sync: {}", e); - } else { - tracing::info!("SPV sync started automatically for {:?}", chosen_network); - } - } + app_state.try_auto_start_spv(); // Refresh ALL main screens so they load data properly // This ensures screens like DashPay Profile have identities loaded @@ -838,55 +610,25 @@ impl AppState { /// /// Default is enabled. pub fn with_animations(self, enabled: bool) -> Self { - self.mainnet_app_context.enable_animations(enabled); - if let Some(context) = self.devnet_app_context.as_ref() { - context.enable_animations(enabled) - } - if let Some(context) = self.testnet_app_context.as_ref() { - context.enable_animations(enabled) + for context in self.network_contexts.values() { + context.enable_animations(enabled); } - if let Some(context) = self.local_app_context.as_ref() { - context.enable_animations(enabled) - } - self } pub fn current_app_context(&self) -> &Arc { - // Invariant: chosen_network must always have a corresponding context. - // Fail fast on violations to avoid silently routing operations to mainnet. - match self.chosen_network { - Network::Mainnet => &self.mainnet_app_context, - Network::Testnet => self.testnet_app_context.as_ref().unwrap_or_else(|| { - panic!( - "BUG: chosen network is Testnet but testnet_app_context is missing; refusing silent mainnet fallback" - ) - }), - Network::Devnet => self.devnet_app_context.as_ref().unwrap_or_else(|| { - panic!( - "BUG: chosen network is Devnet but devnet_app_context is missing; refusing silent mainnet fallback" - ) - }), - Network::Regtest => self.local_app_context.as_ref().unwrap_or_else(|| { + self.network_contexts + .get(&self.chosen_network) + .unwrap_or_else(|| { panic!( - "BUG: chosen network is Regtest but local_app_context is missing; refusing silent mainnet fallback" + "BUG: chosen network is {:?} but its AppContext is missing", + self.chosen_network ) - }), - unsupported_network => panic!( - "BUG: unsupported network variant {:?} in current_app_context; refusing silent mainnet fallback", - unsupported_network - ), - } + }) } fn context_available_for_network(&self, network: Network) -> bool { - match network { - Network::Mainnet => true, // Mainnet is always available - Network::Testnet => self.testnet_app_context.is_some(), - Network::Devnet => self.devnet_app_context.is_some(), - Network::Regtest => self.local_app_context.is_some(), - _ => false, - } + self.network_contexts.contains_key(&network) } fn enforce_network_context_invariant(&mut self) { @@ -904,7 +646,7 @@ impl AppState { // // Uses spawn_blocking + block_on to avoid Send bound issues with platform // SDK types (DataContract/Sdk references across await points). - fn handle_backend_task(&self, task: BackendTask) { + fn handle_backend_task(&mut self, task: BackendTask) { let sender = self.task_result_sender.clone(); let app_context = self.current_app_context().clone(); let handle = tokio::runtime::Handle::current(); @@ -948,6 +690,44 @@ impl AppState { }); } + fn spawn_zmq_listener( + ctx: &Arc, + network: Network, + sender: &egui_mpsc::SenderSync<(ZMQMessage, Network)>, + ) -> Option { + let default_endpoint = match network { + Network::Mainnet => "tcp://127.0.0.1:23708", + Network::Testnet => "tcp://127.0.0.1:23709", + Network::Devnet => "tcp://127.0.0.1:23710", + Network::Regtest => "tcp://127.0.0.1:20302", + _ => return None, + }; + let endpoint = ctx + .config + .read() + .unwrap() + .core_zmq_endpoint + .clone() + .unwrap_or_else(|| default_endpoint.to_string()); + let disable = ctx + .get_settings() + .ok() + .flatten() + .map(|s| s.disable_zmq) + .unwrap_or(false); + if disable { + return None; + } + CoreZMQListener::spawn_listener( + network, + &endpoint, + sender.clone(), + Some(ctx.sx_zmq_status.clone()), + ) + .inspect_err(|e| tracing::error!("Failed to create {network:?} ZMQ listener: {e}")) + .ok() + } + pub fn active_root_screen_mut(&mut self) -> &mut Screen { self.main_screens .get_mut(&self.selected_main_screen) @@ -955,32 +735,31 @@ impl AppState { } pub fn change_network(&mut self, network: Network) { - if !self.context_available_for_network(network) { - let network_name = match network { - Network::Mainnet => "Mainnet", - Network::Testnet => "Testnet", - Network::Devnet => "Devnet", - Network::Regtest => "Local", - _ => "Unknown", - }; - tracing::error!( - "Cannot switch to {:?}: network context not available. Staying on current network.", - network - ); - // Use the current (still active) network's context — egui_ctx is shared - // but this avoids a misleading mainnet_app_context reference when the - // user is on a different network. - let ctx = self.current_app_context(); - MessageBanner::set_global( - ctx.egui_ctx(), - format!( - "Could not connect to {network_name}. Check your network settings and retry." - ), - MessageType::Error, - ); + // Ignore if we're already switching to this network. + if self.network_switch_pending == Some(network) { + return; + } + + // Fast path: context already exists — switch immediately. + if self.context_available_for_network(network) { + self.finalize_network_switch(network); return; } + // Slow path: dispatch SwitchNetwork as a backend task. The result + // (NetworkContextCreated) comes back through the task result channel + // and is handled in update(). Same path used by MCP tools. + self.network_switch_pending = Some(network); + let start_spv = self + .current_app_context() + .db + .get_auto_start_spv() + .unwrap_or(false); + self.handle_backend_task(BackendTask::SwitchNetwork { network, start_spv }); + } + + /// Complete the network switch after the context is available. + fn finalize_network_switch(&mut self, network: Network) { self.chosen_network = network; let app_context = self.current_app_context().clone(); @@ -1010,6 +789,20 @@ impl AppState { handle.clear(); } self.previous_connection_state = None; + + // Spawn a ZMQ listener for the newly created network context. + if !self.zmq_listeners.contains_key(&network) + && let Some(listener) = + Self::spawn_zmq_listener(&app_context, network, &self.core_message_sender) + { + self.zmq_listeners.insert(network, listener); + } + + // Persist the network choice (fast path — slow path persists in the + // BackendTask::SwitchNetwork handler, but fast path skips the handler). + app_context + .update_settings(RootScreenType::RootScreenNetworkChooser) + .ok(); } /// Update the connection status banner when the overall connection state @@ -1104,41 +897,31 @@ impl AppState { self.screen_stack.last_mut().unwrap() } } -} -impl AppState { - // /// This function continuously listens for asset locks and updates the wallets accordingly. - // fn start_listening_for_asset_locks(&mut self) { - // let instant_send_receiver = self.instant_send_receiver.clone(); // Clone the receiver - // let mainnet_app_context = self.mainnet_app_context.clone(); - // let testnet_app_context = self.testnet_app_context.clone(); - // - // // Spawn a new task to listen asynchronously for asset locks - // task::spawn_blocking(move || { - // while let Ok((tx, islock, network)) = instant_send_receiver.recv() { - // let app_context = match network { - // Network::Mainnet => &mainnet_app_context, - // Network::Testnet => { - // if let Some(context) = testnet_app_context.as_ref() { - // context - // } else { - // // Handle the case when testnet_app_context is None - // eprintln!("No testnet app context available for Testnet"); - // continue; // Skip this iteration or handle as needed - // } - // } - // _ => continue, - // }; - // // Store the asset lock transaction in the database - // if let Err(e) = app_context.store_asset_lock_in_db(&tx, islock) { - // eprintln!("Failed to store asset lock: {}", e); - // } - // - // // Sleep briefly to avoid busy-waiting - // std::thread::sleep(Duration::from_millis(50)); - // } - // }); - // } + fn set_main_screen(&mut self, root_screen_type: RootScreenType) { + self.selected_main_screen = root_screen_type; + self.active_root_screen_mut().refresh_on_arrival(); + self.current_app_context() + .update_settings(root_screen_type) + .ok(); + } + + /// Auto-start SPV sync if the conditions are met: auto-start enabled, + /// developer mode on, and backend mode is SPV. + // TODO: SPV auto-start is gated behind developer mode while SPV is in development. + // Remove the is_developer_mode() check once SPV is production-ready. + fn try_auto_start_spv(&self) { + let ctx = self.current_app_context(); + let auto_start = ctx.db.get_auto_start_spv().unwrap_or(false); + if auto_start && ctx.is_developer_mode() && ctx.core_backend_mode() == CoreBackendMode::Spv + { + if let Err(e) = ctx.start_spv() { + tracing::warn!("Failed to auto-start SPV sync: {e}"); + } else { + tracing::info!("SPV sync started automatically for {:?}", ctx.network); + } + } + } } impl App for AppState { @@ -1183,10 +966,7 @@ impl App for AppState { ctx.request_repaint(); } // Render a minimal UI that shows the shutdown banner. - if self.last_applied_theme != Some(self.resolved_theme) { - crate::ui::theme::apply_theme(ctx, self.resolved_theme); - self.last_applied_theme = Some(self.resolved_theme); - } + self.theme.poll_and_apply(ctx); crate::ui::components::styled::island_central_panel(ctx, |_ui| {}); return; } @@ -1229,23 +1009,7 @@ impl App for AppState { } } - // Throttle OS theme detection to every 2 s to prevent white flash from - // transient dark_light::detect() glitches during high-frequency repaints. - if self.theme_preference == ThemeMode::System { - let now = Instant::now(); - if now.duration_since(self.theme_last_checked) >= Duration::from_secs(2) { - self.theme_last_checked = now; - if let Some(detected) = crate::ui::theme::try_detect_system_theme() - && detected != self.resolved_theme - { - self.resolved_theme = detected; - } - } - } - if self.last_applied_theme != Some(self.resolved_theme) { - crate::ui::theme::apply_theme(ctx, self.resolved_theme); - self.last_applied_theme = Some(self.resolved_theme); - } + self.theme.poll_and_apply(ctx); self.enforce_network_context_invariant(); let active_context = self.current_app_context().clone(); @@ -1286,22 +1050,7 @@ impl App for AppState { .display_task_result(unboxed_message); } BackendTaskSuccessResult::UpdatedThemePreference(new_theme) => { - self.theme_preference = new_theme; - let mut detection_failed = false; - self.resolved_theme = if new_theme == ThemeMode::System { - match crate::ui::theme::try_detect_system_theme() { - Some(detected) => detected, - None => { - detection_failed = true; - self.resolved_theme - } - } - } else { - new_theme - }; - self.theme_last_checked = Instant::now(); - crate::ui::theme::apply_theme(ctx, self.resolved_theme); - self.last_applied_theme = Some(self.resolved_theme); + let detection_failed = self.theme.apply_new_preference(ctx, new_theme); if detection_failed { MessageBanner::set_global( ctx, @@ -1336,6 +1085,15 @@ impl App for AppState { ); self.visible_screen_mut().refresh(); } + BackendTaskSuccessResult::NetworkContextCreated { + network, + context, + .. + } => { + self.network_contexts.insert(network, context); + self.network_switch_pending = None; + self.finalize_network_switch(network); + } _ => { // For all other success results, let the screen decide how to display // the outcome without showing a generic global success banner. @@ -1350,6 +1108,10 @@ impl App for AppState { .display_message(&msg, MessageType::Success); self.visible_screen_mut().refresh(); } + TaskResult::Error(err @ TaskError::NetworkContextCreationFailed { .. }) => { + self.network_switch_pending = None; + MessageBanner::set_global(ctx, err.to_string(), MessageType::Error); + } TaskResult::Error(err) => { // Let the screen handle specific error types first. // If handled, skip the generic error banner. @@ -1358,15 +1120,9 @@ impl App for AppState { if !handled { let msg = err.to_string(); let handle = MessageBanner::set_global(ctx, &msg, MessageType::Error); - // Show technical details only in developer mode. - // All user-facing information is in the Display string. - if self.current_app_context().is_developer_mode() { - // INTENTIONAL(SEC-003): TaskError Debug output is shown to users - // in developer mode. This is a local UI app — - // no third parties see this output. Ensure inner error types - // don't expose secrets (see #667). - handle.with_details(&err); - } + // INTENTIONAL(SEC-003): TaskError Debug output is shown to users. + // Ensure inner error types don't expose secrets. + handle.with_details(&err); self.visible_screen_mut() .display_message(&msg, MessageType::Error); } @@ -1377,6 +1133,15 @@ impl App for AppState { } } + // Show a progress banner while a network switch is in progress. + if let Some(pending_network) = self.network_switch_pending { + MessageBanner::set_global( + ctx, + format!("Connecting to {pending_network:?}..."), + MessageType::Info, + ); + } + // Schedule a periodic repaint every ~1 second so timed messages update // their countdown and other periodic UI elements stay current. // Throttled so we don't re-schedule on every frame during user interaction. @@ -1387,33 +1152,9 @@ impl App for AppState { // **Poll the instant_send_receiver for any new InstantSend messages** while let Ok((message, network)) = self.core_message_receiver.try_recv() { - let app_context = match network { - Network::Mainnet => &self.mainnet_app_context, - Network::Testnet => { - if let Some(context) = self.testnet_app_context.as_ref() { - context - } else { - tracing::error!("No testnet app context available for Testnet"); - continue; - } - } - Network::Devnet => { - if let Some(context) = self.devnet_app_context.as_ref() { - context - } else { - tracing::error!("No devnet app context available"); - continue; - } - } - Network::Regtest => { - if let Some(context) = self.local_app_context.as_ref() { - context - } else { - tracing::error!("No local app context available"); - continue; - } - } - _ => continue, + let Some(app_context) = self.network_contexts.get(&network) else { + tracing::error!("No app context available for {:?}", network); + continue; }; match message { ZMQMessage::ISLockedTransaction(tx, is_lock) => { @@ -1572,43 +1313,24 @@ impl App for AppState { self.handle_backend_tasks(tasks, mode); } AppAction::SetMainScreen(root_screen_type) => { - self.selected_main_screen = root_screen_type; - self.active_root_screen_mut().refresh_on_arrival(); - self.current_app_context() - .update_settings(root_screen_type) - .ok(); + self.set_main_screen(root_screen_type); } AppAction::SetMainScreenThenGoToMainScreen(root_screen_type) => { - self.selected_main_screen = root_screen_type; - self.active_root_screen_mut().refresh_on_arrival(); - self.current_app_context() - .update_settings(root_screen_type) - .ok(); + self.set_main_screen(root_screen_type); self.screen_stack = vec![]; } AppAction::SetMainScreenThenPopScreen(root_screen_type) => { - self.selected_main_screen = root_screen_type; - self.active_root_screen_mut().refresh_on_arrival(); - self.current_app_context() - .update_settings(root_screen_type) - .ok(); + self.set_main_screen(root_screen_type); if !self.screen_stack.is_empty() { self.screen_stack.pop(); } } AppAction::SwitchNetwork(network) => { self.change_network(network); - self.current_app_context() - .update_settings(RootScreenType::RootScreenNetworkChooser) - .ok(); } AppAction::PopThenAddScreenToMainScreen(root_screen_type, screen) => { self.screen_stack = vec![screen]; - self.selected_main_screen = root_screen_type; - self.active_root_screen_mut().refresh_on_arrival(); - self.current_app_context() - .update_settings(root_screen_type) - .ok(); + self.set_main_screen(root_screen_type); } AppAction::Custom(_) => {} AppAction::OnboardingComplete { @@ -1617,29 +1339,12 @@ impl App for AppState { } => { self.show_welcome_screen = false; self.welcome_screen = None; - self.selected_main_screen = main_screen; - self.active_root_screen_mut().refresh_on_arrival(); - self.current_app_context().update_settings(main_screen).ok(); - // If there's an additional screen to push, create and push it + self.set_main_screen(main_screen); if let Some(screen_type) = add_screen { let screen = screen_type.create_screen(self.current_app_context()); self.screen_stack.push(screen); } - // Start SPV sync after onboarding completes (if auto-start is enabled and developer mode is on) - // TODO: SPV auto-start is gated behind developer mode while SPV is in development. - // Remove the is_developer_mode() check once SPV is production-ready. - let current_context = self.current_app_context(); - let auto_start_spv = current_context.db.get_auto_start_spv().unwrap_or(false); - if auto_start_spv - && current_context.is_developer_mode() - && current_context.core_backend_mode() == crate::spv::CoreBackendMode::Spv - { - if let Err(e) = current_context.start_spv() { - tracing::warn!("Failed to start SPV sync after onboarding: {}", e); - } else { - tracing::info!("SPV sync started after onboarding"); - } - } + self.try_auto_start_spv(); } } } diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index b27416caa..bf0e471d8 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -14,6 +14,7 @@ use dash_sdk::dpp::consensus::ConsensusError; use dash_sdk::dpp::consensus::basic::basic_error::BasicError; use dash_sdk::dpp::consensus::state::state_error::StateError; use dash_sdk::dpp::dashcore; +use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use thiserror::Error; @@ -387,6 +388,66 @@ pub enum TaskError { )] TokenPositionNotFound { position: u16 }, + /// The token name contains whitespace or control characters. + #[error( + "The token name \"{token_name}\" in {form} contains invalid characters. \ + Token names must not include spaces or control characters. Please rename and try again." + )] + InvalidTokenNameCharacter { + form: String, + token_name: String, + #[source] + source_error: Box, + }, + + /// The token name length is outside the allowed range. + #[error( + "The token {form} is {actual} characters long, but must be between {min} and {max}. \ + Please adjust the name length and try again." + )] + InvalidTokenNameLength { + form: String, + actual: usize, + min: usize, + max: usize, + #[source] + source_error: Box, + }, + + /// The token language code is not recognized. + #[error( + "The language code \"{language_code}\" is not valid. \ + Use a standard language code like \"en\" or \"fr\" and try again." + )] + InvalidTokenLanguageCode { + language_code: String, + #[source] + source_error: Box, + }, + + /// The token's decimal places exceed the platform limit. + #[error( + "Token decimals cannot exceed {max_decimals}, but {decimals} was specified. \ + Please use a smaller value." + )] + TokenDecimalsOverLimit { + decimals: u8, + max_decimals: u8, + #[source] + source_error: Box, + }, + + /// The token's base supply exceeds the platform limit. + #[error( + "The token base supply of {base_supply} is too large. \ + Please use a smaller value." + )] + InvalidTokenBaseSupply { + base_supply: u64, + #[source] + source_error: Box, + }, + // ────────────────────────────────────────────────────────────────────────── // Contract errors // ────────────────────────────────────────────────────────────────────────── @@ -910,6 +971,13 @@ pub enum TaskError { /// Nullifier sync failed. #[error("Could not check for spent shielded notes. Please check your connection and retry.")] ShieldedNullifierSyncFailed { detail: String }, + + // ────────────────────────────────────────────────────────────────────────── + // Network context errors + // ────────────────────────────────────────────────────────────────────────── + /// Creating a network context failed during a network switch. + #[error("Could not connect to {network:?}. Check your network configuration and retry.")] + NetworkContextCreationFailed { network: Network }, } /// Returns `true` when a `dashcore_rpc::Error` wraps an HTTP 401 response, @@ -1151,6 +1219,26 @@ impl From for TaskError { current_count: u64, minimum_required: u64, }, + InvalidTokenNameCharacter { + form: String, + token_name: String, + }, + InvalidTokenNameLength { + form: String, + actual: usize, + min: usize, + max: usize, + }, + InvalidTokenLanguageCode { + language_code: String, + }, + TokenDecimalsOverLimit { + decimals: u8, + max_decimals: u8, + }, + InvalidTokenBaseSupply { + base_supply: u64, + }, } let kind: Option = { @@ -1196,6 +1284,36 @@ impl From for TaskError { minimum_required: e.minimum_required(), }) } + ConsensusError::BasicError(BasicError::InvalidTokenNameCharacterError(e)) => { + Some(ConsensusKind::InvalidTokenNameCharacter { + form: e.form().to_string(), + token_name: e.token_name().to_string(), + }) + } + ConsensusError::BasicError(BasicError::InvalidTokenNameLengthError(e)) => { + Some(ConsensusKind::InvalidTokenNameLength { + form: e.form().to_string(), + actual: e.actual(), + min: e.min(), + max: e.max(), + }) + } + ConsensusError::BasicError(BasicError::InvalidTokenLanguageCodeError(e)) => { + Some(ConsensusKind::InvalidTokenLanguageCode { + language_code: e.language_code().to_string(), + }) + } + ConsensusError::BasicError(BasicError::DecimalsOverLimitError(e)) => { + Some(ConsensusKind::TokenDecimalsOverLimit { + decimals: e.decimals(), + max_decimals: e.max_decimals(), + }) + } + ConsensusError::BasicError(BasicError::InvalidTokenBaseSupplyError(e)) => { + Some(ConsensusKind::InvalidTokenBaseSupply { + base_supply: e.base_supply(), + }) + } _ => None, }) .or_else(|| { @@ -1254,6 +1372,45 @@ impl From for TaskError { minimum_required, source_error: boxed, }, + Some(ConsensusKind::InvalidTokenNameCharacter { form, token_name }) => { + TaskError::InvalidTokenNameCharacter { + form, + token_name, + source_error: boxed, + } + } + Some(ConsensusKind::InvalidTokenNameLength { + form, + actual, + min, + max, + }) => TaskError::InvalidTokenNameLength { + form, + actual, + min, + max, + source_error: boxed, + }, + Some(ConsensusKind::InvalidTokenLanguageCode { language_code }) => { + TaskError::InvalidTokenLanguageCode { + language_code, + source_error: boxed, + } + } + Some(ConsensusKind::TokenDecimalsOverLimit { + decimals, + max_decimals, + }) => TaskError::TokenDecimalsOverLimit { + decimals, + max_decimals, + source_error: boxed, + }, + Some(ConsensusKind::InvalidTokenBaseSupply { base_supply }) => { + TaskError::InvalidTokenBaseSupply { + base_supply, + source_error: boxed, + } + } None => { // Extract timeout duration before consuming boxed. let timeout_secs = if let SdkError::TimeoutReached(d, _) = &*boxed { @@ -1359,6 +1516,10 @@ mod tests { use super::*; use dash_sdk::dapi_client::DapiClientError; use dash_sdk::dapi_client::transport::TransportError; + use dash_sdk::dpp::consensus::basic::data_contract::{ + DecimalsOverLimitError, InvalidTokenBaseSupplyError, InvalidTokenLanguageCodeError, + InvalidTokenNameCharacterError, InvalidTokenNameLengthError, + }; use dash_sdk::dpp::consensus::basic::identity::InvalidInstantAssetLockProofSignatureError; use dash_sdk::dpp::consensus::state::identity::duplicated_identity_public_key_id_state_error::DuplicatedIdentityPublicKeyIdStateError; use dash_sdk::dpp::consensus::state::identity::duplicated_identity_public_key_state_error::DuplicatedIdentityPublicKeyStateError; @@ -2281,4 +2442,223 @@ mod tests { "Connection refused should NOT say 'timed out', got: {msg}" ); } + + // ─── Token validation consensus error tests ────────────────────────────── + + #[test] + fn from_sdk_error_invalid_token_name_character_via_consensus() { + let consensus = ConsensusError::from(InvalidTokenNameCharacterError::new( + "singular form".to_string(), + "token lklimek".to_string(), + )); + let sdk_err = SdkError::from(consensus); + let err = TaskError::from(sdk_err); + assert!(matches!(err, TaskError::InvalidTokenNameCharacter { .. })); + } + + #[test] + fn from_sdk_error_invalid_token_name_character_via_broadcast() { + let consensus = ConsensusError::from(InvalidTokenNameCharacterError::new( + "singular form".to_string(), + "bad name".to_string(), + )); + let broadcast_err = dash_sdk::error::StateTransitionBroadcastError { + code: 10201, + message: "invalid token name character".to_string(), + cause: Some(consensus), + }; + let sdk_err = SdkError::StateTransitionBroadcastError(broadcast_err); + let err = TaskError::from(sdk_err); + assert!(matches!(err, TaskError::InvalidTokenNameCharacter { .. })); + } + + #[test] + fn invalid_token_name_character_display_is_user_friendly() { + let consensus = ConsensusError::from(InvalidTokenNameCharacterError::new( + "singular form".to_string(), + "bad\tname".to_string(), + )); + let sdk_err = SdkError::from(consensus); + let err = TaskError::from(sdk_err); + let msg = err.to_string(); + assert!( + msg.contains("bad\tname"), + "Expected token name in message, got: {msg}" + ); + assert!( + msg.contains("rename"), + "Expected actionable guidance, got: {msg}" + ); + } + + #[test] + fn from_sdk_error_invalid_token_name_length_via_consensus() { + let consensus = + ConsensusError::from(InvalidTokenNameLengthError::new(2, 3, 24, "singular form")); + let sdk_err = SdkError::from(consensus); + let err = TaskError::from(sdk_err); + assert!( + matches!( + err, + TaskError::InvalidTokenNameLength { + actual: 2, + min: 3, + max: 24, + .. + } + ), + "Expected InvalidTokenNameLength, got: {err:?}" + ); + } + + #[test] + fn from_sdk_error_invalid_token_name_length_via_broadcast() { + let consensus = + ConsensusError::from(InvalidTokenNameLengthError::new(50, 3, 24, "singular form")); + let broadcast_err = dash_sdk::error::StateTransitionBroadcastError { + code: 10202, + message: "invalid token name length".to_string(), + cause: Some(consensus), + }; + let sdk_err = SdkError::StateTransitionBroadcastError(broadcast_err); + let err = TaskError::from(sdk_err); + assert!(matches!(err, TaskError::InvalidTokenNameLength { .. })); + } + + #[test] + fn invalid_token_name_length_display_is_user_friendly() { + let consensus = + ConsensusError::from(InvalidTokenNameLengthError::new(2, 3, 24, "singular form")); + let sdk_err = SdkError::from(consensus); + let err = TaskError::from(sdk_err); + let msg = err.to_string(); + assert!(msg.contains("2"), "Expected actual length, got: {msg}"); + assert!(msg.contains("3"), "Expected min length, got: {msg}"); + assert!(msg.contains("24"), "Expected max length, got: {msg}"); + assert!( + msg.contains("adjust"), + "Expected actionable guidance, got: {msg}" + ); + } + + #[test] + fn from_sdk_error_invalid_token_language_code_via_consensus() { + let consensus = + ConsensusError::from(InvalidTokenLanguageCodeError::new("zz_FAKE".to_string())); + let sdk_err = SdkError::from(consensus); + let err = TaskError::from(sdk_err); + assert!(matches!(err, TaskError::InvalidTokenLanguageCode { .. })); + } + + #[test] + fn from_sdk_error_invalid_token_language_code_via_broadcast() { + let consensus = ConsensusError::from(InvalidTokenLanguageCodeError::new("xx".to_string())); + let broadcast_err = dash_sdk::error::StateTransitionBroadcastError { + code: 10203, + message: "invalid language code".to_string(), + cause: Some(consensus), + }; + let sdk_err = SdkError::StateTransitionBroadcastError(broadcast_err); + let err = TaskError::from(sdk_err); + assert!(matches!(err, TaskError::InvalidTokenLanguageCode { .. })); + } + + #[test] + fn invalid_token_language_code_display_is_user_friendly() { + let consensus = + ConsensusError::from(InvalidTokenLanguageCodeError::new("zz_FAKE".to_string())); + let sdk_err = SdkError::from(consensus); + let err = TaskError::from(sdk_err); + let msg = err.to_string(); + assert!( + msg.contains("zz_FAKE"), + "Expected language code in message, got: {msg}" + ); + assert!( + msg.contains("en") || msg.contains("fr"), + "Expected example codes, got: {msg}" + ); + } + + #[test] + fn from_sdk_error_token_decimals_over_limit_via_consensus() { + let consensus = ConsensusError::from(DecimalsOverLimitError::new(20, 8)); + let sdk_err = SdkError::from(consensus); + let err = TaskError::from(sdk_err); + assert!( + matches!( + err, + TaskError::TokenDecimalsOverLimit { + decimals: 20, + max_decimals: 8, + .. + } + ), + "Expected TokenDecimalsOverLimit, got: {err:?}" + ); + } + + #[test] + fn from_sdk_error_token_decimals_over_limit_via_broadcast() { + let consensus = ConsensusError::from(DecimalsOverLimitError::new(20, 8)); + let broadcast_err = dash_sdk::error::StateTransitionBroadcastError { + code: 10204, + message: "decimals over limit".to_string(), + cause: Some(consensus), + }; + let sdk_err = SdkError::StateTransitionBroadcastError(broadcast_err); + let err = TaskError::from(sdk_err); + assert!(matches!(err, TaskError::TokenDecimalsOverLimit { .. })); + } + + #[test] + fn token_decimals_over_limit_display_is_user_friendly() { + let consensus = ConsensusError::from(DecimalsOverLimitError::new(20, 8)); + let sdk_err = SdkError::from(consensus); + let err = TaskError::from(sdk_err); + let msg = err.to_string(); + assert!(msg.contains("20"), "Expected decimals value, got: {msg}"); + assert!(msg.contains("8"), "Expected max decimals, got: {msg}"); + assert!( + msg.contains("smaller value"), + "Expected actionable guidance, got: {msg}" + ); + } + + #[test] + fn from_sdk_error_invalid_token_base_supply_via_consensus() { + let consensus = ConsensusError::from(InvalidTokenBaseSupplyError::new(u64::MAX)); + let sdk_err = SdkError::from(consensus); + let err = TaskError::from(sdk_err); + assert!(matches!(err, TaskError::InvalidTokenBaseSupply { .. })); + } + + #[test] + fn from_sdk_error_invalid_token_base_supply_via_broadcast() { + let consensus = ConsensusError::from(InvalidTokenBaseSupplyError::new(u64::MAX)); + let broadcast_err = dash_sdk::error::StateTransitionBroadcastError { + code: 10205, + message: "invalid base supply".to_string(), + cause: Some(consensus), + }; + let sdk_err = SdkError::StateTransitionBroadcastError(broadcast_err); + let err = TaskError::from(sdk_err); + assert!(matches!(err, TaskError::InvalidTokenBaseSupply { .. })); + } + + #[test] + fn invalid_token_base_supply_display_is_user_friendly() { + let consensus = ConsensusError::from(InvalidTokenBaseSupplyError::new(u64::MAX)); + let sdk_err = SdkError::from(consensus); + let err = TaskError::from(sdk_err); + let msg = err.to_string(); + assert!( + msg.contains(&u64::MAX.to_string()), + "Expected base supply value, got: {msg}" + ); + assert!( + msg.contains("smaller value"), + "Expected actionable guidance, got: {msg}" + ); + } } diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index 7d16293ce..0c560c5cf 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -10,8 +10,8 @@ use crate::backend_task::platform_info::{PlatformInfoTaskRequestType, PlatformIn use crate::backend_task::system_task::SystemTask; use crate::backend_task::wallet::WalletTask; use crate::context::AppContext; -use dash_sdk::dpp::dashcore::Address; -use dash_sdk::dpp::dashcore::address::NetworkChecked; +use crate::spv::CoreBackendMode; +use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::dashcore::bls_sig_utils::BLSSignature; use dash_sdk::dpp::dashcore::network::message_qrinfo::QRInfo; @@ -99,11 +99,25 @@ pub enum BackendTask { GroveSTARKTask(GroveSTARKTask), WalletTask(WalletTask), ShieldedTask(ShieldedTask), - DiscoverDapiNodes { network: Network }, + /// Rebuild the Core RPC client and SDK on the current network context. + /// Dispatched when the user saves a new RPC password so the reinit + /// (which includes DAPI discovery) runs off the UI thread. + ReinitCoreClientAndSdk, + /// Create a new network context and switch to it. + /// Intercepted by `AppState` — never dispatched to `AppContext::run_backend_task`. + /// When `start_spv` is true, SPV sync is started on the new context automatically. + SwitchNetwork { + network: Network, + start_spv: bool, + }, + /// Discover DAPI nodes from the DCG-operated HTTPS service. + DiscoverDapiNodes { + network: Network, + }, None, } -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone)] #[allow(clippy::large_enum_variant)] pub enum BackendTaskSuccessResult { // General results @@ -196,8 +210,8 @@ pub enum BackendTaskSuccessResult { /// Platform address balances fetched from Platform PlatformAddressBalances { seed_hash: WalletSeedHash, - /// Map of address to (balance, nonce) - balances: BTreeMap, (u64, u32)>, + /// Map of platform address to (balance, nonce) + balances: BTreeMap, }, /// Platform credits transferred between addresses PlatformCreditsTransferred { @@ -333,6 +347,18 @@ pub enum BackendTaskSuccessResult { amount: u64, }, ProvingKeyReady, + + /// Core RPC client and SDK were successfully rebuilt (e.g. after password change). + CoreClientReinitialized, + + /// A new network context was created asynchronously during a network switch. + NetworkContextCreated { + network: Network, + context: Arc, + spv_started: bool, + }, + + /// Fresh DAPI node addresses discovered from the DCG service. DapiNodesDiscovered { network: Network, count: usize, @@ -423,6 +449,52 @@ impl AppContext { BackendTask::ShieldedTask(shielded_task) => { Ok(self.run_shielded_task(shielded_task).await?) } + BackendTask::ReinitCoreClientAndSdk => { + Arc::clone(self).reinit_core_client_and_sdk()?; + Ok(BackendTaskSuccessResult::CoreClientReinitialized) + } + BackendTask::SwitchNetwork { network, start_spv } => { + // Create a new AppContext for the target network, reusing shared + // resources (db, subtasks, connection_status) from the current context. + let new_ctx = AppContext::new( + self.data_dir.clone(), + network, + self.db.clone(), + self.password_info.clone(), + self.subtasks.clone(), + self.connection_status.clone(), + self.egui_ctx().clone(), + ) + .ok_or(TaskError::NetworkContextCreationFailed { network })?; + // Persist the network choice so subsequent startups (GUI, CLI, + // MCP) all begin on the same network. + new_ctx + .update_settings(crate::ui::RootScreenType::RootScreenNetworkChooser) + .ok(); + + let spv_started = if start_spv { + if new_ctx.core_backend_mode() != CoreBackendMode::Spv { + new_ctx.set_core_backend_mode_volatile(CoreBackendMode::Spv); + } + match new_ctx.start_spv() { + Ok(()) => { + tracing::info!(?network, "SPV started after network switch"); + true + } + Err(e) => { + tracing::warn!(?network, "SPV start failed after network switch: {e}"); + false + } + } + } else { + false + }; + Ok(BackendTaskSuccessResult::NetworkContextCreated { + network, + context: new_ctx, + spv_started, + }) + } BackendTask::DiscoverDapiNodes { network } => { let devnet_name = self .config diff --git a/src/backend_task/platform_info.rs b/src/backend_task/platform_info.rs index 4ca839142..fe928c76d 100644 --- a/src/backend_task/platform_info.rs +++ b/src/backend_task/platform_info.rs @@ -381,7 +381,7 @@ fn format_withdrawal_documents_to_bare_info( impl AppContext { pub async fn run_platform_info_task( - &self, + self: &Arc, request: PlatformInfoTaskRequestType, sdk: &Sdk, ) -> Result { diff --git a/src/backend_task/wallet/fetch_platform_address_balances.rs b/src/backend_task/wallet/fetch_platform_address_balances.rs index 359d73802..d1fb8f32b 100644 --- a/src/backend_task/wallet/fetch_platform_address_balances.rs +++ b/src/backend_task/wallet/fetch_platform_address_balances.rs @@ -5,6 +5,7 @@ use crate::model::wallet::{ WalletSeedHash, }; use dash_sdk::RequestSettings; +use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::key_wallet::bip32::DerivationPath; use dash_sdk::platform::address_sync::AddressSyncConfig; @@ -169,7 +170,11 @@ impl AppContext { wallet .platform_address_info .iter() - .map(|(addr, info)| (addr.clone(), (info.balance, info.nonce))) + .filter_map(|(addr, info)| { + PlatformAddress::try_from(addr.clone()) + .ok() + .map(|pa| (pa, (info.balance, info.nonce))) + }) .collect() }; diff --git a/src/config.rs b/src/config.rs index 54a6a93d0..03426d07c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -68,13 +68,13 @@ impl NetworkConfig { /// - Mainnet: 9998 /// - Testnet: 19998 /// - Devnet: 29998 - /// - Regtest: 20302 (dashmate default, matches `.env.example`) + /// - Regtest: 19898 pub fn default_rpc_port(network: Network) -> u16 { match network { Network::Mainnet => 9998, Network::Testnet => 19998, Network::Devnet => 29998, - Network::Regtest => 20302, + Network::Regtest => 19898, _ => 9998, } } @@ -328,7 +328,7 @@ impl Config { impl NetworkConfig { /// List of DAPI addresses, if explicitly configured. - /// Returns `Ok(None)` when absent or empty (not configured; the user may trigger discovery from Network Settings). + /// Returns `Ok(None)` when absent or empty (dynamic discovery should be used). pub fn dapi_address_list(&self) -> Result, String> { let addrs = match self.dapi_addresses.as_deref() { Some(a) => a.trim(), diff --git a/src/context/mod.rs b/src/context/mod.rs index 9c6ad5a37..2ea63ffd9 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -438,13 +438,26 @@ impl AppContext { } pub fn set_core_backend_mode(self: &Arc, mode: CoreBackendMode) { + self.set_core_backend_mode_inner(mode, true); + } + + /// Switch the backend mode in-memory only, without persisting to the DB. + /// Used by headless (MCP/CLI) mode to force SPV without overwriting the + /// GUI's saved preference. + pub fn set_core_backend_mode_volatile(self: &Arc, mode: CoreBackendMode) { + self.set_core_backend_mode_inner(mode, false); + } + + fn set_core_backend_mode_inner(self: &Arc, mode: CoreBackendMode, persist: bool) { self.core_backend_mode .store(mode.as_u8(), Ordering::Relaxed); - // Persist the mode to the database (hold the guard to ensure cache invalidation) - let _guard = self.invalidate_settings_cache(); - if let Err(e) = self.db.update_core_backend_mode(mode.as_u8()) { - tracing::error!("Failed to persist core backend mode: {}", e); + if persist { + // Persist the mode to the database (hold the guard to ensure cache invalidation) + let _guard = self.invalidate_settings_cache(); + if let Err(e) = self.db.update_core_backend_mode(mode.as_u8()) { + tracing::error!("Failed to persist core backend mode: {}", e); + } } // Switch SDK context provider to match the selected backend. @@ -495,9 +508,30 @@ impl AppContext { } /// Update the cached platform protocol version from epoch info. - pub fn set_platform_protocol_version(&self, version: u32) { - self.platform_protocol_version - .store(version, Ordering::Relaxed); + /// + /// When the version crosses the shielded threshold for the first time, + /// retroactively initializes shielded wallets that were unlocked before + /// the protocol version was known. + pub fn set_platform_protocol_version(self: &Arc, version: u32) { + let old = self + .platform_protocol_version + .swap(version, Ordering::Relaxed); + + if old < Self::SHIELDED_MIN_PROTOCOL_VERSION + && version >= Self::SHIELDED_MIN_PROTOCOL_VERSION + { + self.init_missing_shielded_wallets(); + } + } + + /// Minimum protocol version required for shielded (ZK) transactions. + pub const SHIELDED_MIN_PROTOCOL_VERSION: u32 = 12; + + /// Whether the connected network supports shielded (ZK) transactions. + /// Returns `true` when the network's protocol version >= 12. + /// Returns `false` when the version hasn't been fetched yet (0). + pub fn supports_shielded(&self) -> bool { + self.platform_protocol_version() >= Self::SHIELDED_MIN_PROTOCOL_VERSION } /// Get a fee estimator configured with the cached fee multiplier. @@ -552,9 +586,18 @@ impl AppContext { // Note: developer_mode is now global and managed separately - // 2. Rebuild the RPC client (cookie auth → user/pass fallback) + // 2. Rebuild the RPC client with the new password let addr = format!("http://{}:{}", cfg.rpc_host(), cfg.rpc_port(self.network)); - let new_client = Self::create_core_rpc_client(&addr, self.network, &cfg.devnet_name, &cfg)?; + let new_client = Client::new( + &addr, + Auth::UserPass( + cfg.core_rpc_user.clone().unwrap_or_default(), + cfg.core_rpc_password.clone().unwrap_or_default(), + ), + ) + .map_err(|e| TaskError::RpcProviderCreationFailed { + detail: e.to_string(), + })?; // 3. Parse DAPI addresses from config and rebuild the SDK let address_list = match &cfg.dapi_addresses { diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index e8c739915..2cb3ec120 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -162,10 +162,10 @@ impl AppContext { // Core UTXO refresh is handled at startup in bootstrap_loaded_wallets. // Initialize shielded wallet state only when the network supports it - // (all shielded state transitions present in the platform version). - // On mainnet (which doesn't support shielded transactions yet), skip - // entirely to avoid unnecessary sync attempts and log noise. - if crate::model::feature_gate::FeatureGate::Shielded.is_available(self) { + // (protocol version >= 12, i.e., Platform v3.1+). On mainnet (which + // doesn't support shielded transactions yet), skip entirely to avoid + // unnecessary sync attempts and log noise. + if self.supports_shielded() { match self.initialize_shielded_wallet(seed_hash) { Ok(_) => { tracing::trace!( @@ -195,6 +195,46 @@ impl AppContext { self.queue_spv_wallet_unload(seed_hash); } + /// Initialize shielded state for unlocked wallets that were skipped + /// because the protocol version wasn't known at unlock time. + /// Called when the protocol version first crosses the shielded threshold. + pub(crate) fn init_missing_shielded_wallets(self: &Arc) { + // Collect candidate seed hashes while holding locks, then release + // before calling initialize_shielded_wallet (which re-acquires both). + let candidates: Vec = (|| { + let wallets = self.wallets.read().ok()?; + let existing = self.shielded_states.lock().ok()?; + Some( + wallets + .iter() + .filter(|(hash, wallet_arc)| { + !existing.contains_key(*hash) + && wallet_arc.read().ok().map(|w| w.is_open()).unwrap_or(false) + }) + .map(|(hash, _)| *hash) + .collect(), + ) + })() + .unwrap_or_default(); + + for seed_hash in candidates { + match self.initialize_shielded_wallet(seed_hash) { + Ok(_) => { + tracing::info!( + seed = %hex::encode(seed_hash), + "Shielded wallet initialized after protocol version update" + ); + self.queue_shielded_sync(seed_hash); + } + Err(e) => tracing::debug!( + seed = %hex::encode(seed_hash), + error = %e, + "Shielded wallet init failed after protocol version update" + ), + } + } + } + /// Queue async SyncNotes -> CheckNullifiers for an already-initialized /// shielded wallet. Tracked via `subtasks` so it participates in graceful /// shutdown and cancellation. @@ -723,7 +763,8 @@ impl AppContext { /// Reconcile SPV wallet state into DET. pub async fn reconcile_spv_wallets(&self) -> Result<(), TaskError> { let wm_arc = self.spv_manager.wallet(); - let wm = wm_arc.read().await; + let wm: tokio::sync::RwLockReadGuard<'_, dash_sdk::dpp::key_wallet_manager::WalletManager> = + wm_arc.read().await; let mapping = self.spv_manager.det_wallets_snapshot(); // Take a snapshot of known addresses per wallet so we can scope DB updates diff --git a/src/mcp/error.rs b/src/mcp/error.rs index fe5eb4713..20a8c89ec 100644 --- a/src/mcp/error.rs +++ b/src/mcp/error.rs @@ -31,18 +31,28 @@ const CODE_INTERNAL: i32 = -32603; // standard JSON-RPC internal error impl From for McpError { fn from(e: McpToolError) -> Self { - let (code, msg) = match &e { - McpToolError::WalletNotFound { .. } => (CODE_WALLET_NOT_FOUND, e.to_string()), - McpToolError::InvalidParam { .. } => (CODE_INVALID_PARAM, e.to_string()), - McpToolError::NetworkMismatch { .. } => (CODE_NETWORK_MISMATCH, e.to_string()), - McpToolError::SpvSyncFailed => (CODE_SPV_SYNC_FAILED, e.to_string()), - McpToolError::TaskFailed(_) => (CODE_TASK_FAILED, e.to_string()), - McpToolError::Internal(_) => (CODE_INTERNAL, e.to_string()), + let (code, msg, data) = match &e { + McpToolError::WalletNotFound { .. } => (CODE_WALLET_NOT_FOUND, e.to_string(), None), + McpToolError::InvalidParam { .. } => (CODE_INVALID_PARAM, e.to_string(), None), + McpToolError::NetworkMismatch { .. } => (CODE_NETWORK_MISMATCH, e.to_string(), None), + McpToolError::SpvSyncFailed => (CODE_SPV_SYNC_FAILED, e.to_string(), None), + McpToolError::TaskFailed(task_err) => { + // Include the full Debug error chain so MCP clients can see + // the underlying cause (e.g. SDK/DAPI errors) instead of just + // the user-friendly Display message. + let details = format!("{task_err:?}"); + ( + CODE_TASK_FAILED, + e.to_string(), + Some(serde_json::Value::String(details)), + ) + } + McpToolError::Internal(_) => (CODE_INTERNAL, e.to_string(), None), }; McpError { code: ErrorCode(code), message: msg.into(), - data: None, + data, } } } diff --git a/src/mcp/resolve.rs b/src/mcp/resolve.rs index ef0c42d20..469c4aa44 100644 --- a/src/mcp/resolve.rs +++ b/src/mcp/resolve.rs @@ -105,6 +105,15 @@ pub(crate) fn wallet_arc( } /// Wait for SPV to reach fully-synced (green) state. +/// +/// Required for **all wallet-facing tools** — both core-chain (UTXOs, sending +/// Dash) and platform queries (address balances, withdrawals). Even DAPI-only +/// operations need SPV because the SDK verifies DAPI proofs against quorum and +/// masternode list data from the synced chain. When a second client is running, +/// SPV falls back to a tempdir and must sync before any proof verification works. +/// +/// Only tools that make no network calls (e.g. `core_wallets_list`, +/// `network_info`, `tool_describe`) skip this gate. pub(crate) async fn ensure_spv_synced(ctx: &AppContext) -> Result<(), McpToolError> { let deadline = tokio::time::Instant::now() + SPV_WAIT_TIMEOUT; loop { diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 3e1f528fd..8be0c3546 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -2,29 +2,57 @@ use crate::context::AppContext; use crate::mcp::tools; +use crate::spv::CoreBackendMode; use rmcp::handler::server::tool::{ToolCallContext, ToolRouter}; use rmcp::model::*; use rmcp::{ErrorData as McpError, RoleServer, ServerHandler, service::RequestContext}; use std::sync::Arc; -/// Abstracts how the MCP service obtains its AppContext. +/// Abstracts how the MCP service stores and swaps its AppContext. +/// Both variants support `load` and `store` for network switching. #[derive(Clone)] -enum ContextProvider { - /// HTTP mode: context provided by the GUI app, follows network switches. +enum ContextHolder { + /// HTTP mode: shared with the GUI app via the same `ArcSwap`. + /// GUI calls `store()` on network switch; MCP sees it immediately. #[cfg(feature = "mcp")] Shared(Arc>), - /// Stdio/CLI mode: lazily initialized on first use. + /// Stdio/CLI mode: standalone, lazily initialized on first tool call. #[cfg(feature = "cli")] - Lazy(Arc>>), + Standalone(Arc>), +} + +impl ContextHolder { + fn load(&self) -> Option> { + match self { + #[cfg(feature = "mcp")] + Self::Shared(swap) => Some(swap.load_full()), + #[cfg(feature = "cli")] + Self::Standalone(swap) => swap.load_full(), + } + } + + fn store(&self, ctx: Arc) { + match self { + #[cfg(feature = "mcp")] + Self::Shared(swap) => swap.store(ctx), + #[cfg(feature = "cli")] + Self::Standalone(swap) => swap.store(Some(ctx)), + } + } } /// MCP service backed by the app's context. /// -/// Works with both transports: HTTP (shared ArcSwap context from the GUI app) -/// and stdio (lazily initialized standalone context). +/// HTTP mode shares the GUI's `ArcSwap` so network switches propagate +/// bidirectionally. Stdio/CLI mode uses a standalone `ArcSwapOption` with +/// lazy initialization. Both modes support `swap_context` for the +/// `network_switch` tool. #[derive(Clone)] pub struct DashMcpService { - ctx_provider: ContextProvider, + ctx: ContextHolder, + /// Guards lazy initialization in stdio/CLI mode. + #[cfg(feature = "cli")] + init_guard: Arc>, pub(crate) tool_router: ToolRouter, } @@ -35,11 +63,13 @@ impl std::fmt::Debug for DashMcpService { } impl DashMcpService { - /// For HTTP mode: wrap an existing shared context. + /// For HTTP mode: wrap the GUI's shared ArcSwap (same reference). #[cfg(feature = "mcp")] pub fn new_shared(app_context: Arc>) -> Self { Self { - ctx_provider: ContextProvider::Shared(app_context), + ctx: ContextHolder::Shared(app_context), + #[cfg(feature = "cli")] + init_guard: Arc::new(tokio::sync::OnceCell::const_new_with(())), tool_router: Self::tool_router(), } } @@ -48,32 +78,45 @@ impl DashMcpService { #[cfg(feature = "cli")] pub fn new_lazy() -> Self { Self { - ctx_provider: ContextProvider::Lazy(Arc::new(tokio::sync::OnceCell::new())), + ctx: ContextHolder::Standalone(Arc::new(arc_swap::ArcSwapOption::empty())), + init_guard: Arc::new(tokio::sync::OnceCell::new()), tool_router: Self::tool_router(), } } - /// Get the current AppContext. In HTTP mode, loads from ArcSwap. - /// In stdio mode, initializes on first call. + /// Get the current AppContext. /// - /// Each tool must call this exactly once and pass the resulting `Arc` to - /// both validation and the operation to avoid TOCTOU issues with ArcSwap. + /// In HTTP mode, loads from the shared ArcSwap (always initialized). + /// In stdio/CLI mode, initializes on first call, then loads. pub(crate) async fn ctx(&self) -> Result, McpError> { - match &self.ctx_provider { - #[cfg(feature = "mcp")] - ContextProvider::Shared(swap) => Ok(swap.load_full()), - #[cfg(feature = "cli")] - ContextProvider::Lazy(cell) => cell - .get_or_try_init(|| async { init_app_context().await }) - .await - .cloned(), + #[cfg(feature = "cli")] + if let ContextHolder::Standalone(_) = &self.ctx { + let ctx_holder = self.ctx.clone(); + self.init_guard + .get_or_try_init(|| async { + let app_context = init_app_context().await?; + ctx_holder.store(app_context); + Ok::<(), McpError>(()) + }) + .await?; } + self.ctx + .load() + .ok_or_else(|| McpError::internal_error("AppContext not initialized", None)) + } + + /// Replace the active context. Used by `network_switch` to point the + /// server at a newly created network context. Works in all modes. + pub(crate) fn swap_context(&self, new_ctx: Arc) { + self.ctx.store(new_ctx); } /// Build the tool router using trait-based tool composition. pub fn tool_router() -> ToolRouter { ToolRouter::new() .with_async_tool::() + .with_async_tool::() + .with_async_tool::() .with_async_tool::() .with_async_tool::() .with_async_tool::() @@ -209,6 +252,13 @@ pub async fn init_app_context() -> Result, McpError> { ) })?; + // Headless mode has no Dash Core RPC — force SPV backend so wallet + // tools work without a local node. + if app_context.core_backend_mode() != CoreBackendMode::Spv { + tracing::info!("Headless mode: forcing SPV backend (was RPC)"); + app_context.set_core_backend_mode_volatile(CoreBackendMode::Spv); + } + if let Err(e) = app_context.start_spv() { tracing::warn!("SPV start failed (wallet tools may not work): {e}"); } else { diff --git a/src/mcp/tools/network.rs b/src/mcp/tools/network.rs index b26a1fa2b..9b4eddc0c 100644 --- a/src/mcp/tools/network.rs +++ b/src/mcp/tools/network.rs @@ -5,11 +5,15 @@ use std::borrow::Cow; use rmcp::handler::server::router::tool::{AsyncTool, ToolBase}; use rmcp::model::ToolAnnotations; use rmcp::schemars; -use serde::Serialize; +use serde::{Deserialize, Serialize}; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; +use crate::mcp::dispatch::dispatch_task; use crate::mcp::error::McpToolError; +use crate::mcp::resolve; use crate::mcp::server::{DashMcpService, collect_available, network_display_name}; use crate::mcp::tools::EmptyParams; +use dash_sdk::dpp::dashcore::Network; // --------------------------------------------------------------------------- // NetworkTool @@ -67,3 +71,190 @@ impl AsyncTool for NetworkTool { Ok(NetworkOutput { active, available }) } } + +// --------------------------------------------------------------------------- +// NetworkReinitSdk +// --------------------------------------------------------------------------- + +/// Rebuild the Core RPC client and Platform SDK using the current network +/// configuration. Use this after changing RPC credentials or DAPI addresses +/// to apply the new settings without restarting the app. +pub struct NetworkReinitSdk; + +#[derive(Debug, Deserialize, schemars::JsonSchema, Default)] +pub struct ReinitSdkParams { + /// Target network. Required — must match the server's active network. + pub network: String, +} + +#[derive(Serialize, schemars::JsonSchema)] +pub struct ReinitSdkOutput { + success: bool, +} + +impl ToolBase for NetworkReinitSdk { + type Parameter = ReinitSdkParams; + type Output = ReinitSdkOutput; + type Error = McpToolError; + + fn name() -> Cow<'static, str> { + "network_reinit_sdk".into() + } + + fn description() -> Option> { + Some( + "Rebuild the Core RPC client and Platform SDK using the current network \ + configuration. Use after changing RPC credentials or DAPI addresses." + .into(), + ) + } + + fn annotations() -> Option { + Some( + ToolAnnotations::default() + .read_only(false) + .destructive(false) + .idempotent(true) + .open_world(true), + ) + } +} + +impl AsyncTool for NetworkReinitSdk { + async fn invoke( + service: &DashMcpService, + param: ReinitSdkParams, + ) -> Result { + let ctx = service + .ctx() + .await + .map_err(|e| McpToolError::Internal(e.to_string()))?; + resolve::require_network(&ctx, Some(¶m.network))?; + + let task = BackendTask::ReinitCoreClientAndSdk; + let result = dispatch_task(&ctx, task) + .await + .map_err(McpToolError::TaskFailed)?; + + match result { + BackendTaskSuccessResult::CoreClientReinitialized => { + Ok(ReinitSdkOutput { success: true }) + } + other => Err(McpToolError::Internal(format!( + "Unexpected task result: {other:?}" + ))), + } + } +} + +// --------------------------------------------------------------------------- +// NetworkSwitch +// --------------------------------------------------------------------------- + +/// Switch the active network. Creates a new context for the target network +/// if needed, then swaps the MCP server to use it. +pub struct NetworkSwitch; + +#[derive(Debug, Deserialize, schemars::JsonSchema, Default)] +pub struct NetworkSwitchParams { + /// Target network (e.g. "mainnet", "testnet", "devnet", "local"). + pub network: String, +} + +#[derive(Serialize, schemars::JsonSchema)] +pub struct NetworkSwitchOutput { + /// The network that is now active. + active: String, + /// Whether SPV was successfully started on the new network context. + spv_started: bool, +} + +impl ToolBase for NetworkSwitch { + type Parameter = NetworkSwitchParams; + type Output = NetworkSwitchOutput; + type Error = McpToolError; + + fn name() -> Cow<'static, str> { + "network_switch".into() + } + + fn description() -> Option> { + Some( + "Switch the active network. Creates the context if needed (may take \ + a few seconds). Requires that the target network has DAPI addresses \ + configured." + .into(), + ) + } + + fn annotations() -> Option { + Some( + ToolAnnotations::default() + .read_only(false) + .destructive(false) + .idempotent(true) + .open_world(true), + ) + } +} + +/// Parse a network name string into a `Network` enum value. +fn parse_network(name: &str) -> Result { + match name.to_lowercase().as_str() { + "mainnet" | "main" => Ok(Network::Mainnet), + "testnet" | "test" => Ok(Network::Testnet), + "devnet" | "dev" => Ok(Network::Devnet), + "regtest" | "local" => Ok(Network::Regtest), + other => Err(McpToolError::InvalidParam { + message: format!("Unknown network '{other}'. Use mainnet, testnet, devnet, or local."), + }), + } +} + +impl AsyncTool for NetworkSwitch { + async fn invoke( + service: &DashMcpService, + param: NetworkSwitchParams, + ) -> Result { + let target = parse_network(¶m.network)?; + + let ctx = service + .ctx() + .await + .map_err(|e| McpToolError::Internal(e.to_string()))?; + + // Already on the target network — no-op. + if ctx.network() == target { + return Ok(NetworkSwitchOutput { + active: network_display_name(target).to_owned(), + spv_started: true, + }); + } + + // Dispatch SwitchNetwork through the standard backend task system. + let task = BackendTask::SwitchNetwork { + network: target, + start_spv: true, + }; + let result = dispatch_task(&ctx, task) + .await + .map_err(McpToolError::TaskFailed)?; + + match result { + BackendTaskSuccessResult::NetworkContextCreated { + context, + spv_started, + .. + } => { + service.swap_context(context); + Ok(NetworkSwitchOutput { + active: network_display_name(target).to_owned(), + spv_started, + }) + } + other => Err(McpToolError::Internal(format!( + "Unexpected task result: {other:?}" + ))), + } + } +} diff --git a/src/mcp/tools/wallet.rs b/src/mcp/tools/wallet.rs index 8b76506ab..01b95f45d 100644 --- a/src/mcp/tools/wallet.rs +++ b/src/mcp/tools/wallet.rs @@ -342,6 +342,9 @@ impl AsyncTool for FetchPlatformBalances { resolve::verify_network(&ctx, param.network.as_deref())?; let seed_hash = resolve::wallet(&ctx, ¶m.wallet_id)?; + // SPV is required: DAPI proof verification needs quorum/masternode list + // data from the synced chain. When a second client is running, SPV falls + // back to a tempdir and must sync before platform queries can succeed. resolve::ensure_spv_synced(&ctx).await?; let task = BackendTask::WalletTask(WalletTask::FetchPlatformAddressBalances { seed_hash }); @@ -351,10 +354,11 @@ impl AsyncTool for FetchPlatformBalances { match result { BackendTaskSuccessResult::PlatformAddressBalances { balances, .. } => { + let network = ctx.network(); let entries = balances .into_iter() .map(|(addr, (balance, nonce))| PlatformAddressBalance { - address: addr.to_string(), + address: addr.to_bech32m_string(network), balance, nonce, }) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index e460c6c97..f2c6ce60c 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -771,117 +771,146 @@ pub enum Screen { impl Screen { pub fn change_context(&mut self, app_context: Arc) { + /// Assigns `app_context` for the majority of screen variants that simply + /// store it as a field. Only screens with additional side-effects are + /// handled in the explicit match arms below. + macro_rules! set_ctx { + ($($variant:ident),+ $(,)?) => { + match self { + $(Screen::$variant(screen) => screen.app_context = app_context,)+ + _ => {} + } + } + } + + // Screens with side-effects on context change are handled first. + // Everything else falls through to the macro default assignment. match self { - Screen::IdentitiesScreen(screen) => screen.app_context = app_context, - Screen::DPNSScreen(screen) => screen.app_context = app_context, - Screen::AddExistingIdentityScreen(screen) => screen.app_context = app_context, - Screen::KeyInfoScreen(screen) => screen.app_context = app_context, - Screen::KeysScreen(screen) => screen.app_context = app_context, - Screen::WithdrawalScreen(screen) => screen.app_context = app_context, - Screen::TransitionVisualizerScreen(screen) => screen.app_context = app_context, - Screen::ContractVisualizerScreen(screen) => screen.app_context = app_context, - Screen::NetworkChooserScreen(screen) => screen.current_network = app_context.network, - Screen::AddKeyScreen(screen) => screen.app_context = app_context, - Screen::DocumentQueryScreen(screen) => screen.app_context = app_context, - Screen::AddNewIdentityScreen(screen) => screen.app_context = app_context, - Screen::RegisterDpnsNameScreen(screen) => screen.app_context = app_context, - Screen::RegisterDataContractScreen(screen) => screen.app_context = app_context, - Screen::UpdateDataContractScreen(screen) => screen.app_context = app_context, - Screen::DocumentActionScreen(screen) => screen.app_context = app_context, - Screen::GroupActionsScreen(screen) => screen.app_context = app_context, + Screen::NetworkChooserScreen(screen) => { + let network = app_context.network; + screen.network_contexts.insert(network, app_context); + screen.current_network = network; + return; + } Screen::AddNewWalletScreen(screen) => { screen.app_context = app_context; screen.reset_core_wallets_cache(); + return; } Screen::TransferScreen(screen) => { screen.app_context = app_context; screen.invalidate_address_input(); + return; } - Screen::TopUpIdentityScreen(screen) => screen.app_context = app_context, Screen::WalletsBalancesScreen(screen) => { screen.app_context = app_context; screen.reset_pending_list_state(); screen.update_selected_wallet_for_network(); screen.invalidate_address_inputs(); + return; } Screen::ImportMnemonicScreen(screen) => { screen.app_context = app_context; screen.reset_core_wallets_cache(); + return; } Screen::WalletSendScreen(screen) => { screen.app_context = app_context; screen.invalidate_address_input(); + return; } - Screen::SingleKeyWalletSendScreen(screen) => screen.app_context = app_context, - Screen::ProofLogScreen(screen) => screen.app_context = app_context, - Screen::AddContractsScreen(screen) => screen.app_context = app_context, - Screen::ProofVisualizerScreen(screen) => screen.app_context = app_context, Screen::MasternodeListDiffScreen(screen) => { let old_net = screen.app_context.network; if old_net != app_context.network { - // Switch context and clear state to avoid cross-network bleed screen.app_context = app_context.clone(); screen.clear(); } else { screen.app_context = app_context; } + return; } - Screen::DocumentVisualizerScreen(screen) => screen.app_context = app_context, - Screen::PlatformInfoScreen(screen) => screen.app_context = app_context, - Screen::GroveSTARKScreen(screen) => screen.app_context = app_context, Screen::AddressBalanceScreen(screen) => { screen.app_context = app_context; screen.invalidate_address_input(); + return; } - - // Token Screens - Screen::TokensScreen(screen) => screen.app_context = app_context, - Screen::TransferTokensScreen(screen) => screen.app_context = app_context, - Screen::MintTokensScreen(screen) => screen.app_context = app_context, - Screen::BurnTokensScreen(screen) => screen.app_context = app_context, - Screen::DestroyFrozenFundsScreen(screen) => screen.app_context = app_context, - Screen::FreezeTokensScreen(screen) => screen.app_context = app_context, - Screen::UnfreezeTokensScreen(screen) => screen.app_context = app_context, - Screen::PauseTokensScreen(screen) => screen.app_context = app_context, - Screen::ResumeTokensScreen(screen) => screen.app_context = app_context, - Screen::ClaimTokensScreen(screen) => screen.app_context = app_context, - Screen::ViewTokenClaimsScreen(screen) => screen.app_context = app_context, - Screen::UpdateTokenConfigScreen(screen) => screen.app_context = app_context, - Screen::AddTokenById(screen) => screen.app_context = app_context, - Screen::PurchaseTokenScreen(screen) => screen.app_context = app_context, - Screen::SetTokenPriceScreen(screen) => screen.app_context = app_context, - Screen::AssetLockDetailScreen(screen) => screen.app_context = app_context, - Screen::CreateAssetLockScreen(screen) => screen.app_context = app_context, - - // DashPay Screens Screen::DashPayScreen(screen) => { screen.app_context = app_context.clone(); screen.contacts_list.app_context = app_context.clone(); screen.contacts_list.contact_requests.app_context = app_context.clone(); screen.profile_screen.app_context = app_context.clone(); screen.payment_history.app_context = app_context; + return; } - Screen::DashPayAddContactScreen(screen) => screen.app_context = app_context, - Screen::DashPayContactDetailsScreen(screen) => screen.app_context = app_context, - Screen::DashPayContactProfileViewerScreen(screen) => screen.app_context = app_context, - Screen::DashPaySendPaymentScreen(screen) => screen.app_context = app_context, - Screen::DashPayContactInfoEditorScreen(screen) => screen.app_context = app_context, - Screen::DashPayQRGeneratorScreen(screen) => screen.app_context = app_context, - Screen::DashPayProfileSearchScreen(screen) => screen.app_context = app_context, - // Shielded screens Screen::ShieldScreen(screen) => { - screen.app_context = app_context.clone(); + screen.app_context = app_context; screen.invalidate_address_input(); + return; } Screen::ShieldedSendScreen(screen) => { - screen.app_context = app_context.clone(); + screen.app_context = app_context; screen.invalidate_address_input(); + return; } Screen::UnshieldCreditsScreen(screen) => { - screen.app_context = app_context.clone(); + screen.app_context = app_context; screen.invalidate_address_input(); + return; } + _ => {} } + + // Simple context assignment for all remaining screens. + set_ctx!( + IdentitiesScreen, + DPNSScreen, + AddExistingIdentityScreen, + KeyInfoScreen, + KeysScreen, + WithdrawalScreen, + TransitionVisualizerScreen, + ContractVisualizerScreen, + AddKeyScreen, + DocumentQueryScreen, + AddNewIdentityScreen, + RegisterDpnsNameScreen, + RegisterDataContractScreen, + UpdateDataContractScreen, + DocumentActionScreen, + GroupActionsScreen, + TopUpIdentityScreen, + SingleKeyWalletSendScreen, + ProofLogScreen, + AddContractsScreen, + ProofVisualizerScreen, + DocumentVisualizerScreen, + PlatformInfoScreen, + GroveSTARKScreen, + TokensScreen, + TransferTokensScreen, + MintTokensScreen, + BurnTokensScreen, + DestroyFrozenFundsScreen, + FreezeTokensScreen, + UnfreezeTokensScreen, + PauseTokensScreen, + ResumeTokensScreen, + ClaimTokensScreen, + ViewTokenClaimsScreen, + UpdateTokenConfigScreen, + AddTokenById, + PurchaseTokenScreen, + SetTokenPriceScreen, + AssetLockDetailScreen, + CreateAssetLockScreen, + DashPayAddContactScreen, + DashPayContactDetailsScreen, + DashPayContactProfileViewerScreen, + DashPaySendPaymentScreen, + DashPayContactInfoEditorScreen, + DashPayQRGeneratorScreen, + DashPayProfileSearchScreen, + ); } } diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index a8a6cc7dc..ef2bcaf4a 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -7,7 +7,6 @@ use crate::context::AppContext; use crate::context::connection_status::{ConnectionStatus, OverallConnectionState}; use crate::model::wallet::DerivationPathHelpers; use crate::spv::{CoreBackendMode, SpvStatus, SpvStatusSnapshot}; -use crate::ui::components::MessageBanner; use crate::ui::components::component_trait::Component; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::password_input::PasswordInput; @@ -15,6 +14,7 @@ use crate::ui::components::styled::{ ConfirmationDialog, ConfirmationStatus, StyledCard, StyledCheckbox, island_central_panel, }; use crate::ui::components::top_panel::add_top_panel; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::theme::{DashColors, ResponseExt, Shape, ThemeMode}; use crate::ui::{MessageType, RootScreenType, ScreenLike}; use crate::utils::path::format_path_for_display; @@ -22,7 +22,7 @@ use dash_sdk::dash_spv::sync::{ProgressPercentage, SyncProgress as SpvSyncProgre use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::identity::TimestampMillis; use eframe::egui::{self, Context, Ui}; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -83,10 +83,11 @@ fn add_dapi_status_label( } pub struct NetworkChooserScreen { - pub mainnet_app_context: Arc, - pub testnet_app_context: Option>, - pub devnet_app_context: Option>, - pub local_app_context: Option>, + pub network_contexts: BTreeMap>, + /// Shared data directory (same for all networks). + data_dir: PathBuf, + /// Shared database handle (same for all networks). + db: Arc, dashmate_password_input: PasswordInput, pub current_network: Network, pub recheck_time: Option, @@ -110,40 +111,43 @@ pub struct NetworkChooserScreen { use_local_spv_node: bool, auto_start_spv: bool, close_dash_qt_on_exit: bool, + /// Tracks whether the last config save to disk failed (needed to show the + /// correct banner when the async reinit completes). + config_save_failed: bool, + /// Progress banner shown while reinit runs in the background. + reinit_banner: Option, discovery_in_progress: bool, - fetch_confirm_dialog: Option, + show_fetch_confirmation: bool, } impl NetworkChooserScreen { pub fn new( - mainnet_app_context: &Arc, - testnet_app_context: Option<&Arc>, - devnet_app_context: Option<&Arc>, - local_app_context: Option<&Arc>, + contexts: &BTreeMap>, current_network: Network, overwrite_dash_conf: bool, ) -> Self { + let any_context = contexts + .values() + .next() + .expect("BUG: NetworkChooserScreen requires at least one AppContext"); + + let data_dir = any_context.data_dir.clone(); + let db = any_context.db.clone(); + let mut dashmate_password_input = PasswordInput::new() .with_hint_text("Core RPC password") .with_char_limit(40) .with_desired_width(280.0); - if let Ok(config) = Config::load_from(&mainnet_app_context.data_dir) + if let Ok(config) = Config::load_from(&data_dir) && let Some(network_config) = config.config_for_network(current_network) { dashmate_password_input .set_text(network_config.core_rpc_password.clone().unwrap_or_default()); } - let current_context = match current_network { - Network::Mainnet => mainnet_app_context, - Network::Testnet => testnet_app_context.unwrap_or(mainnet_app_context), - Network::Devnet => devnet_app_context.unwrap_or(mainnet_app_context), - Network::Regtest => local_app_context.unwrap_or(mainnet_app_context), - _ => mainnet_app_context, - }; + let current_context = contexts.get(¤t_network).unwrap_or(any_context); let developer_mode = current_context.is_developer_mode(); - // Load settings including theme preference and dash_qt_path let settings = current_context .get_settings() .ok() @@ -152,42 +156,30 @@ impl NetworkChooserScreen { let theme_preference = settings.theme_mode; let disable_zmq = settings.disable_zmq; let custom_dash_qt_path = settings.dash_qt_path; - let use_local_spv_node = mainnet_app_context - .db - .get_use_local_spv_node() - .unwrap_or(false); - let auto_start_spv = mainnet_app_context.db.get_auto_start_spv().unwrap_or(false); - let close_dash_qt_on_exit = mainnet_app_context - .db - .get_close_dash_qt_on_exit() - .unwrap_or(true); + let use_local_spv_node = db.get_use_local_spv_node().unwrap_or(false); + let auto_start_spv = db.get_auto_start_spv().unwrap_or(false); + let close_dash_qt_on_exit = db.get_close_dash_qt_on_exit().unwrap_or(true); let mut backend_modes = HashMap::new(); - backend_modes.insert(Network::Mainnet, mainnet_app_context.core_backend_mode()); - backend_modes.insert( + for network in [ + Network::Mainnet, Network::Testnet, - testnet_app_context - .map(|ctx| ctx.core_backend_mode()) - .unwrap_or_default(), - ); - backend_modes.insert( Network::Devnet, - devnet_app_context - .map(|ctx| ctx.core_backend_mode()) - .unwrap_or_default(), - ); - backend_modes.insert( Network::Regtest, - local_app_context - .map(|ctx| ctx.core_backend_mode()) - .unwrap_or_default(), - ); + ] { + backend_modes.insert( + network, + contexts + .get(&network) + .map(|ctx| ctx.core_backend_mode()) + .unwrap_or_default(), + ); + } Self { - mainnet_app_context: mainnet_app_context.clone(), - testnet_app_context: testnet_app_context.cloned(), - devnet_app_context: devnet_app_context.cloned(), - local_app_context: local_app_context.cloned(), + network_contexts: contexts.clone(), + data_dir, + db, dashmate_password_input, current_network, recheck_time: None, @@ -211,29 +203,23 @@ impl NetworkChooserScreen { use_local_spv_node, auto_start_spv, close_dash_qt_on_exit, + config_save_failed: false, + reinit_banner: None, discovery_in_progress: false, - fetch_confirm_dialog: None, + show_fetch_confirmation: false, } } - pub fn context_for_network(&self, network: Network) -> &Arc { - match network { - Network::Mainnet => &self.mainnet_app_context, - Network::Testnet if self.testnet_app_context.is_some() => { - self.testnet_app_context.as_ref().unwrap() - } - Network::Devnet if self.devnet_app_context.is_some() => { - self.devnet_app_context.as_ref().unwrap() - } - Network::Regtest if self.local_app_context.is_some() => { - self.local_app_context.as_ref().unwrap() - } - _ => &self.mainnet_app_context, - } + pub fn context_for_network(&self, network: Network) -> Option<&Arc> { + self.network_contexts.get(&network) } + /// Returns the AppContext for the current network. + /// Falls back to any available context (should always succeed while the app is running). pub fn current_app_context(&self) -> &Arc { self.context_for_network(self.current_network) + .or_else(|| self.network_contexts.values().next()) + .expect("BUG: no AppContext available for any network") } /// Save the current settings to the database @@ -387,6 +373,7 @@ impl NetworkChooserScreen { { app_action = AppAction::SwitchNetwork(Network::Mainnet); } + // Testnet always visible; Devnet/Local only in dev mode if ui .selectable_value( &mut self.current_network, @@ -421,7 +408,7 @@ impl NetworkChooserScreen { } if self.current_network != prev_network { let password = Config::load_from( - &self.mainnet_app_context.data_dir, + &self.data_dir, ) .ok() .and_then(|c| { @@ -488,7 +475,7 @@ impl NetworkChooserScreen { if (save_clicked || auto_update_succeeded) && let Ok(mut config) = - Config::load_from(&self.mainnet_app_context.data_dir) + Config::load_from(&self.data_dir) && let Some(network_cfg) = config.config_for_network(self.current_network).clone() { @@ -500,7 +487,7 @@ impl NetworkChooserScreen { updated_config.clone(), ); let save_failed = if let Err(e) = - config.save(&self.mainnet_app_context.data_dir) + config.save(&self.data_dir) { tracing::error!("Failed to save config to .env: {e}"); true @@ -508,74 +495,43 @@ impl NetworkChooserScreen { false }; - // Update in-memory config and reinit regardless of save - // result, so the password takes effect for this session. - // Only do so when the context for this network already - // exists — otherwise `context_for_network` would silently - // fall back to mainnet and corrupt its config. The saved - // file-level config will be picked up when the network - // context is created. - let network_context_exists = match self.current_network { - Network::Mainnet => true, - Network::Testnet => self.testnet_app_context.is_some(), - Network::Devnet => self.devnet_app_context.is_some(), - Network::Regtest => self.local_app_context.is_some(), - _ => false, - }; - - let reinit_failed = if network_context_exists { - let app_context = self.context_for_network(self.current_network); + // Update in-memory config and dispatch an async reinit + // so the password takes effect for this session without + // blocking the UI thread. Only do so when the context + // for this network already exists — otherwise + // `context_for_network` would silently fall back to + // mainnet and corrupt its config. The saved file-level + // config will be picked up when the network context is + // created. + if let Some(app_context) = self.context_for_network(self.current_network) + { { let mut cfg_lock = app_context.config.write().unwrap(); *cfg_lock = updated_config; } MessageBanner::clear_all_global(ui.ctx()); - if let Err(e) = - Arc::clone(app_context).reinit_core_client_and_sdk() - { - tracing::error!( - "Failed to re-init RPC client and sdk for {:?}: {}", - self.current_network, - e - ); - true - } else { - false - } + self.config_save_failed = save_failed; + self.reinit_banner = Some(MessageBanner::set_global( + ui.ctx(), + "Reconnecting to Dash Core...", + MessageType::Info, + )); + app_action = AppAction::BackendTask( + BackendTask::ReinitCoreClientAndSdk, + ); + } else if save_failed { + MessageBanner::set_global( + ui.ctx(), + "Could not save the configuration file. Your changes will apply when this network is activated.", + MessageType::Warning, + ); } else { - false - }; - - match (save_failed, reinit_failed) { - (false, false) => { - MessageBanner::set_global( - ui.ctx(), - "Core RPC password saved successfully.", - MessageType::Success, - ); - } - (false, true) => { - MessageBanner::set_global( - ui.ctx(), - "Password saved but the connection could not be re-established. Check that Dash Core is running and retry.", - MessageType::Warning, - ); - } - (true, false) => { - MessageBanner::set_global( - ui.ctx(), - "Could not save the configuration file. Your changes will apply for this session only.", - MessageType::Warning, - ); - } - (true, true) => { - MessageBanner::set_global( - ui.ctx(), - "Could not save the configuration file and the connection could not be re-established. Check that Dash Core is running and retry.", - MessageType::Warning, - ); - } + MessageBanner::set_global( + ui.ctx(), + "Core RPC password saved successfully.", + MessageType::Success, + ); } } }); @@ -832,18 +788,7 @@ impl NetworkChooserScreen { ); if clicked { if dapi_total > 0 { - self.fetch_confirm_dialog = Some( - ConfirmationDialog::new( - "Update Node Addresses?", - format!( - "This will fetch a fresh list of DAPI nodes, replacing \ - your current {dapi_total} configured addresses in the \ - config file." - ), - ) - .confirm_text(Some("Fetch")) - .cancel_text(Some("Cancel")), - ); + self.show_fetch_confirmation = true; } else { self.discovery_in_progress = true; app_action = AppAction::BackendTask( @@ -857,19 +802,33 @@ impl NetworkChooserScreen { }); // Fetch confirmation dialog - if let Some(dialog) = self.fetch_confirm_dialog.as_mut() { - let response = dialog.show(ui); - if let Some(result) = response.inner.dialog_response { - self.fetch_confirm_dialog = None; - if result == ConfirmationStatus::Confirmed { - self.discovery_in_progress = true; - app_action = AppAction::BackendTask( - BackendTask::DiscoverDapiNodes { - network: self.current_network, - }, - ); - } - } + if self.show_fetch_confirmation { + egui::Window::new("Update Node Addresses?") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) + .show(ui.ctx(), |ui| { + ui.label(format!( + "This will fetch a fresh list of DAPI nodes, replacing your current {} \ + configured addresses in the config file.", + dapi_total + )); + ui.add_space(12.0); + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + self.show_fetch_confirmation = false; + } + if ui.button("Fetch").clicked() { + self.show_fetch_confirmation = false; + self.discovery_in_progress = true; + app_action = AppAction::BackendTask( + BackendTask::DiscoverDapiNodes { + network: self.current_network, + }, + ); + } + }); + }); } }); @@ -1164,23 +1123,14 @@ impl NetworkChooserScreen { .clickable_tooltip("Show advanced options for power users and developers") .clicked() { - // Always update all contexts first to keep UI in sync - self.mainnet_app_context - .enable_developer_mode(self.developer_mode); - if let Some(ref ctx) = self.testnet_app_context { - ctx.enable_developer_mode(self.developer_mode); - } - if let Some(ref ctx) = self.devnet_app_context { - ctx.enable_developer_mode(self.developer_mode); - } - if let Some(ref ctx) = self.local_app_context { + for ctx in self.network_contexts.values() { ctx.enable_developer_mode(self.developer_mode); } // Persist to config file (non-blocking for UI) - if let Ok(mut config) = Config::load_from(&self.mainnet_app_context.data_dir) { + if let Ok(mut config) = Config::load_from(&self.data_dir) { config.developer_mode = Some(self.developer_mode); - if let Err(e) = config.save(&self.mainnet_app_context.data_dir) { + if let Err(e) = config.save(&self.data_dir) { tracing::error!("Failed to save config: {e}"); } } @@ -1188,33 +1138,12 @@ impl NetworkChooserScreen { // TODO: When developer mode is disabled, stop SPV and switch to RPC. // Remove this block once SPV is production-ready. if !self.developer_mode { - // Stop SPV and switch to RPC for all network contexts - self.mainnet_app_context.stop_spv(); - if self.mainnet_app_context.core_backend_mode() == CoreBackendMode::Spv { - self.mainnet_app_context.set_core_backend_mode(CoreBackendMode::Rpc); - } - self.backend_modes.insert(Network::Mainnet, CoreBackendMode::Rpc); - - if let Some(ref ctx) = self.testnet_app_context { - ctx.stop_spv(); - if ctx.core_backend_mode() == CoreBackendMode::Spv { - ctx.set_core_backend_mode(CoreBackendMode::Rpc); - } - self.backend_modes.insert(Network::Testnet, CoreBackendMode::Rpc); - } - if let Some(ref ctx) = self.devnet_app_context { - ctx.stop_spv(); - if ctx.core_backend_mode() == CoreBackendMode::Spv { - ctx.set_core_backend_mode(CoreBackendMode::Rpc); - } - self.backend_modes.insert(Network::Devnet, CoreBackendMode::Rpc); - } - if let Some(ref ctx) = self.local_app_context { + for (&network, ctx) in &self.network_contexts { ctx.stop_spv(); if ctx.core_backend_mode() == CoreBackendMode::Spv { ctx.set_core_backend_mode(CoreBackendMode::Rpc); } - self.backend_modes.insert(Network::Regtest, CoreBackendMode::Rpc); + self.backend_modes.insert(network, CoreBackendMode::Rpc); } } } @@ -1308,7 +1237,6 @@ impl NetworkChooserScreen { { // Save to database match self - .mainnet_app_context .db .update_close_dash_qt_on_exit(self.close_dash_qt_on_exit) { @@ -1366,21 +1294,10 @@ impl NetworkChooserScreen { { // Save to database let _ = self - .mainnet_app_context .db .update_use_local_spv_node(self.use_local_spv_node); - // Update all network contexts - self.mainnet_app_context - .spv_manager() - .set_use_local_node(self.use_local_spv_node); - if let Some(ref ctx) = self.testnet_app_context { - ctx.spv_manager().set_use_local_node(self.use_local_spv_node); - } - if let Some(ref ctx) = self.devnet_app_context { - ctx.spv_manager().set_use_local_node(self.use_local_spv_node); - } - if let Some(ref ctx) = self.local_app_context { + for ctx in self.network_contexts.values() { ctx.spv_manager().set_use_local_node(self.use_local_spv_node); } } @@ -1430,7 +1347,6 @@ impl NetworkChooserScreen { { // Save to database let _ = self - .mainnet_app_context .db .update_auto_start_spv(self.auto_start_spv); } @@ -2058,13 +1974,7 @@ impl NetworkChooserScreen { } fn has_context_for(&self, network: Network) -> bool { - match network { - Network::Mainnet => true, - Network::Testnet => self.testnet_app_context.is_some(), - Network::Devnet => self.devnet_app_context.is_some(), - Network::Regtest => self.local_app_context.is_some(), - _ => false, - } + self.network_contexts.contains_key(&network) } fn spv_status_detail(&self, snapshot: &SpvStatusSnapshot) -> Option { @@ -2160,21 +2070,8 @@ impl ScreenLike for NetworkChooserScreen { self.theme_preference = settings.theme_mode; } - self.backend_modes.insert( - Network::Mainnet, - self.mainnet_app_context.core_backend_mode(), - ); - if let Some(ctx) = &self.testnet_app_context { - self.backend_modes - .insert(Network::Testnet, ctx.core_backend_mode()); - } - if let Some(ctx) = &self.devnet_app_context { - self.backend_modes - .insert(Network::Devnet, ctx.core_backend_mode()); - } - if let Some(ctx) = &self.local_app_context { - self.backend_modes - .insert(Network::Regtest, ctx.core_backend_mode()); + for (&network, ctx) in &self.network_contexts { + self.backend_modes.insert(network, ctx.core_backend_mode()); } } @@ -2224,15 +2121,31 @@ impl ScreenLike for NetworkChooserScreen { action } - fn display_message(&mut self, _message: &str, message_type: MessageType) { - // Only reset discovery state on errors — other message types (success, - // info) may be unrelated global banners (theme change, scheduled votes, etc.) - if matches!(message_type, MessageType::Error) && self.discovery_in_progress { - self.discovery_in_progress = false; + fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { + // Handle CoreClientReinitialized (from RPC password save) + if matches!( + &backend_task_success_result, + BackendTaskSuccessResult::CoreClientReinitialized + ) { + self.reinit_banner.take_and_clear(); + let save_failed = std::mem::take(&mut self.config_save_failed); + + if save_failed { + MessageBanner::set_global( + self.current_app_context().egui_ctx(), + "Could not save the configuration file. Your changes will apply for this session only.", + MessageType::Warning, + ); + } else { + MessageBanner::set_global( + self.current_app_context().egui_ctx(), + "Core RPC password saved successfully.", + MessageType::Success, + ); + } } - } - fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { + // Handle DapiNodesDiscovered (from "Refresh DAPI endpoints" button) if let BackendTaskSuccessResult::DapiNodesDiscovered { network, count, @@ -2241,83 +2154,47 @@ impl ScreenLike for NetworkChooserScreen { { self.discovery_in_progress = false; - // Use current context for data_dir and egui_ctx — they are shared - // across all network contexts. - let current_ctx = self.current_app_context().clone(); - // Update config with new addresses - let mut config = match Config::load_from(¤t_ctx.data_dir) { - Ok(c) => c, - Err(e) => { - MessageBanner::set_global( - current_ctx.egui_ctx(), - format!("Discovered {count} node addresses but could not load settings to save them."), - MessageType::Error, - ) - .with_details(e); - return; - } - }; - - // Use existing network config or create a fresh one if this network - // has no config block yet (e.g. Testnet with no TESTNET_* vars in .env). - let mut network_cfg = config - .config_for_network(network) - .clone() - .unwrap_or_default(); - network_cfg.dapi_addresses = Some(addresses_csv); - config.update_config_for_network(network, network_cfg.clone()); - - if let Err(e) = config.save(¤t_ctx.data_dir) { - MessageBanner::set_global( - current_ctx.egui_ctx(), - format!("Discovered {count} node addresses but failed to save settings. Addresses will be lost on restart."), - MessageType::Error, - ) - .with_details(e); - return; - } - - // Update in-memory config and reinit SDK - let network_context_exists = match network { - Network::Mainnet => true, - Network::Testnet => self.testnet_app_context.is_some(), - Network::Devnet => self.devnet_app_context.is_some(), - Network::Regtest => self.local_app_context.is_some(), - _ => false, - }; + let data_dir = &self.current_app_context().data_dir; + if let Ok(mut config) = Config::load_from(data_dir) + && let Some(mut network_cfg) = config.config_for_network(network).clone() + { + network_cfg.dapi_addresses = Some(addresses_csv); + config.update_config_for_network(network, network_cfg.clone()); - if !network_context_exists { - MessageBanner::set_global( - current_ctx.egui_ctx(), - format!("Discovered {count} node addresses. Restart the app to apply them."), - MessageType::Info, - ); - return; - } + if let Err(e) = config.save(data_dir) { + tracing::error!("Failed to save config after DAPI discovery: {e}"); + } - let app_context = self.context_for_network(network); - { - if let Ok(mut cfg_lock) = app_context.config.write() { - *cfg_lock = network_cfg; + // Update in-memory config and reinit SDK if context exists + if let Some(app_context) = self.context_for_network(network) { + if let Ok(mut cfg_lock) = app_context.config.write() { + *cfg_lock = network_cfg; + } + if let Err(e) = Arc::clone(app_context).reinit_core_client_and_sdk() { + tracing::error!( + "Failed to reinit SDK after DAPI discovery for {:?}: {}", + network, + e + ); + } } - } - if let Err(e) = Arc::clone(app_context).reinit_core_client_and_sdk() { MessageBanner::set_global( - current_ctx.egui_ctx(), - format!("Updated to {count} node addresses but reconnection failed. You may need to restart the app."), - MessageType::Warning, - ) - .with_details(e); - return; + self.current_app_context().egui_ctx(), + format!("Updated to {count} node addresses."), + MessageType::Success, + ); } + } + } - MessageBanner::set_global( - current_ctx.egui_ctx(), - format!("Updated to {count} node addresses."), - MessageType::Success, - ); + fn display_message(&mut self, _msg: &str, msg_type: MessageType) { + self.reinit_banner.take_and_clear(); + self.config_save_failed = false; + // Only reset discovery state on errors — other message types may be unrelated + if matches!(msg_type, MessageType::Error) && self.discovery_in_progress { + self.discovery_in_progress = false; } } } diff --git a/src/ui/tokens/add_token_by_id_screen.rs b/src/ui/tokens/add_token_by_id_screen.rs index 4979908ba..fb0dd4e70 100644 --- a/src/ui/tokens/add_token_by_id_screen.rs +++ b/src/ui/tokens/add_token_by_id_screen.rs @@ -134,7 +134,7 @@ impl AddTokenByIdScreen { fn render_add_button(&mut self, ui: &mut Ui) -> AppAction { if let (Some(contract), Some(tok)) = (&self.fetched_contract, &self.selected_token) - && ComponentStyles::add_primary_button(ui, "Add Token").clicked() + && ComponentStyles::add_primary_button(ui, "Import Token").clicked() { let insert_mode = InsertTokensToo::SomeTokensShouldBeAdded(vec![tok.token_position]); @@ -313,7 +313,7 @@ impl ScreenLike for AddTokenByIdScreen { &self.app_context, vec![ ("Tokens", AppAction::GoToMainScreen), - ("Add Token", AppAction::None), + ("Import Token", AppAction::None), ], vec![], ); @@ -334,7 +334,7 @@ impl ScreenLike for AddTokenByIdScreen { return self.show_success_screen(ui); } - ui.heading("Add Token"); + ui.heading("Import Token"); ui.add_space(10.0); ui.label("Enter either a Contract ID or Token ID to search for tokens."); diff --git a/src/ui/tokens/tokens_screen/mod.rs b/src/ui/tokens/tokens_screen/mod.rs index ca1d46f82..b477c23a3 100644 --- a/src/ui/tokens/tokens_screen/mod.rs +++ b/src/ui/tokens/tokens_screen/mod.rs @@ -2824,7 +2824,7 @@ impl ScreenLike for TokensScreen { let right_buttons = match self.tokens_subscreen { TokensSubscreen::MyTokens => vec![ ( - "Add Token", + "Import Token", DesiredAppAction::AddScreenType(Box::new(ScreenType::AddTokenById)), ), ( diff --git a/src/ui/tokens/tokens_screen/my_tokens.rs b/src/ui/tokens/tokens_screen/my_tokens.rs index 8a66e535b..5f53b036c 100644 --- a/src/ui/tokens/tokens_screen/my_tokens.rs +++ b/src/ui/tokens/tokens_screen/my_tokens.rs @@ -262,7 +262,7 @@ impl TokensScreen { match self.tokens_subscreen { TokensSubscreen::MyTokens => { let button = egui::Button::new( - RichText::new("Add Token") + RichText::new("Import Token") .color(egui::Color32::WHITE) .strong(), ) diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 60f36c81c..ca6acdb9d 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -11,7 +11,6 @@ use crate::backend_task::shielded::ShieldedTask; use crate::context::AppContext; use crate::context::connection_status::spv_phase_summary; use crate::model::amount::Amount; -use crate::model::feature_gate::FeatureGate; use crate::model::wallet::{TransactionStatus, Wallet, WalletSeedHash, WalletTransaction}; use crate::spv::{CoreBackendMode, SpvStatus}; use crate::ui::components::component_trait::Component; @@ -1089,7 +1088,7 @@ impl WalletsBalancesScreen { } // Dev-mode buttons: right-aligned, filling all remaining space - if FeatureGate::DeveloperMode.is_available(&self.app_context) { + if self.app_context.is_developer_mode() { let remaining = ui.available_width(); ui.allocate_ui_with_layout( egui::vec2(remaining, ui.min_size().y), @@ -1173,8 +1172,8 @@ impl WalletsBalancesScreen { } // Add the Shielded tab only when the connected network supports it - // (all shielded state transitions present in the platform version). - if FeatureGate::Shielded.is_available(&self.app_context) { + // (protocol version >= 12, i.e., Platform v3.1+). + if self.app_context.supports_shielded() { tabs.push(AccountTab::Shielded); } @@ -2010,7 +2009,7 @@ impl WalletsBalancesScreen { .color(DashColors::text_primary(dark_mode)) .size(25.0), ); - if FeatureGate::DeveloperMode.is_available(&self.app_context) { + if self.app_context.is_developer_mode() { ui.label( RichText::new("[DEV]") .color(DashColors::text_secondary(dark_mode)) @@ -2882,9 +2881,11 @@ impl ScreenLike for WalletsBalancesScreen { && let Ok(mut wallet) = selected.write() && wallet.seed_hash() == seed_hash { - // Update balances in the wallet - for (addr, (balance, nonce)) in balances { - wallet.set_platform_address_info(addr, balance, nonce); + // Convert PlatformAddress back to Core Address for wallet storage + let network = self.app_context.network(); + for (platform_addr, (balance, nonce)) in balances { + let core_addr = platform_addr.to_address_with_network(network); + wallet.set_platform_address_info(core_addr, balance, nonce); } } self.refresh_platform_sync_info_cache(&seed_hash); From f89a07549476efd6ef7690ddb2248778b5883e31 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 8 Apr 2026 09:37:43 +0200 Subject: [PATCH 02/10] fix(app): use FeatureGate::Shielded instead of naive supports_shielded() check Co-Authored-By: Claude Opus 4.6 (1M context) --- src/context/mod.rs | 20 +++++--------------- src/context/wallet_lifecycle.rs | 7 ++++--- src/ui/wallets/wallets_screen/mod.rs | 6 +++--- 3 files changed, 12 insertions(+), 21 deletions(-) diff --git a/src/context/mod.rs b/src/context/mod.rs index 2ea63ffd9..cdf354af8 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -15,6 +15,7 @@ use crate::config::{Config, NetworkConfig}; use crate::context_provider::Provider as RpcProvider; use crate::context_provider_spv::SpvProvider; use crate::database::Database; +use crate::model::feature_gate::FeatureGate; use crate::model::fee_estimation::PlatformFeeEstimator; use crate::model::password_info::PasswordInfo; use crate::model::proof_log_item::RequestType; @@ -513,27 +514,16 @@ impl AppContext { /// retroactively initializes shielded wallets that were unlocked before /// the protocol version was known. pub fn set_platform_protocol_version(self: &Arc, version: u32) { - let old = self - .platform_protocol_version + let was_shielded = FeatureGate::Shielded.is_available(self); + + self.platform_protocol_version .swap(version, Ordering::Relaxed); - if old < Self::SHIELDED_MIN_PROTOCOL_VERSION - && version >= Self::SHIELDED_MIN_PROTOCOL_VERSION - { + if !was_shielded && FeatureGate::Shielded.is_available(self) { self.init_missing_shielded_wallets(); } } - /// Minimum protocol version required for shielded (ZK) transactions. - pub const SHIELDED_MIN_PROTOCOL_VERSION: u32 = 12; - - /// Whether the connected network supports shielded (ZK) transactions. - /// Returns `true` when the network's protocol version >= 12. - /// Returns `false` when the version hasn't been fetched yet (0). - pub fn supports_shielded(&self) -> bool { - self.platform_protocol_version() >= Self::SHIELDED_MIN_PROTOCOL_VERSION - } - /// Get a fee estimator configured with the cached fee multiplier. /// Use this instead of `PlatformFeeEstimator::new()` to get accurate fee estimates /// that reflect the current network fee multiplier. diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index 2cb3ec120..770403e1f 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -2,6 +2,7 @@ use super::AppContext; use super::get_transaction_info; use crate::backend_task::error::TaskError; use crate::database::is_unique_constraint_violation; +use crate::model::feature_gate::FeatureGate; use crate::model::wallet::{ AddressInfo as WalletAddressInfo, DerivationPathHelpers, DerivationPathReference, DerivationPathType, TransactionStatus, Wallet, WalletSeedHash, WalletTransaction, @@ -162,10 +163,10 @@ impl AppContext { // Core UTXO refresh is handled at startup in bootstrap_loaded_wallets. // Initialize shielded wallet state only when the network supports it - // (protocol version >= 12, i.e., Platform v3.1+). On mainnet (which - // doesn't support shielded transactions yet), skip entirely to avoid + // (all shielded state transitions present). On mainnet (which doesn't + // support shielded transactions yet), skip entirely to avoid // unnecessary sync attempts and log noise. - if self.supports_shielded() { + if FeatureGate::Shielded.is_available(self) { match self.initialize_shielded_wallet(seed_hash) { Ok(_) => { tracing::trace!( diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index ca6acdb9d..d0fe6c576 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -11,6 +11,7 @@ use crate::backend_task::shielded::ShieldedTask; use crate::context::AppContext; use crate::context::connection_status::spv_phase_summary; use crate::model::amount::Amount; +use crate::model::feature_gate::FeatureGate; use crate::model::wallet::{TransactionStatus, Wallet, WalletSeedHash, WalletTransaction}; use crate::spv::{CoreBackendMode, SpvStatus}; use crate::ui::components::component_trait::Component; @@ -1171,9 +1172,8 @@ impl WalletsBalancesScreen { tabs.insert(0, AccountTab::Category(AccountCategory::Bip44, Some(0))); } - // Add the Shielded tab only when the connected network supports it - // (protocol version >= 12, i.e., Platform v3.1+). - if self.app_context.supports_shielded() { + // Add the Shielded tab only when the connected network supports it. + if FeatureGate::Shielded.is_available(&self.app_context) { tabs.push(AccountTab::Shielded); } From 121565195d90cbde4b7267fa061ae024f0828d81 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:24:06 +0200 Subject: [PATCH 03/10] =?UTF-8?q?fix(review):=20wave=201=20=E2=80=94=20doc?= =?UTF-8?q?=20comments,=20stale=20config,=20error=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PROJ-001: use unwrap_or_default() in DapiNodesDiscovered handler so addresses are saved even when the network has no prior config entry - PROJ-002: fix SwitchNetwork doc comment — it IS dispatched to run_backend_task, not intercepted by AppState - PROJ-003: update CLAUDE.md MCP context provider names to match current code (ContextHolder::Shared / ContextHolder::Standalone) - PROJ-005: correct LOCAL_core_rpc_port in .env.example from 20302 to 19898 - CODE-006: use Display format ({network}) instead of Debug ({network:?}) in NetworkContextCreationFailed error message - CODE-008: remove duplicate update_settings() call from SwitchNetwork backend task handler; finalize_network_switch() already persists it Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 2 +- CLAUDE.md | 2 +- src/app.rs | 3 +-- src/backend_task/error.rs | 2 +- src/backend_task/mod.rs | 9 ++------- src/ui/network_chooser_screen.rs | 8 +++++--- 6 files changed, 11 insertions(+), 15 deletions(-) diff --git a/.env.example b/.env.example index 96b68dbdb..f1022b9a9 100644 --- a/.env.example +++ b/.env.example @@ -27,7 +27,7 @@ DEVNET_core_zmq_endpoint=tcp://127.0.0.1:23710 # See docs/local-network.md for detailed setup instructions. LOCAL_dapi_addresses=http://127.0.0.1:2443,http://127.0.0.1:2543,http://127.0.0.1:2643 LOCAL_core_host=127.0.0.1 -LOCAL_core_rpc_port=20302 +LOCAL_core_rpc_port=19898 LOCAL_core_rpc_user=dashmate # Use dashmate cli to retrive it: # dashmate config get core.rpc.users.dashmate.password --config=local_seed diff --git a/CLAUDE.md b/CLAUDE.md index ed6bea124..3b6a448b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,7 +109,7 @@ User-facing error messages (shown in `MessageBanner` via `Display`) must follow - **CLI ≠ MCP**: `src/bin/det_cli/` is a separate client that talks to the MCP server — it must work over HTTP too, not just in-process. Never put tool logic in the CLI binary; tools live in `src/mcp/tools/` and the CLI discovers them dynamically via `tools/list`. - **Tool architecture**: each tool is a struct implementing `ToolBase` (metadata) + `AsyncTool` (invocation). Adding a tool requires only the struct + registering in `tool_router()` — zero CLI changes. - **Tool naming**: `{domain}_{object}_{action}` — e.g. `core_address_create`, `platform_withdrawals_get`, `tool_describe`. CLI converts underscores to hyphens. -- **Context provider**: `ContextProvider::Shared(ArcSwap)` for HTTP mode (follows GUI network switches), `ContextProvider::Lazy(OnceCell)` for stdio (init on first tool call). +- **Context provider**: `ContextHolder::Shared(ArcSwap)` for HTTP mode (follows GUI network switches), `ContextHolder::Standalone(ArcSwapOption)` for stdio (init on first tool call). - **Network safety**: tools accept optional `network` param — request fails if it doesn't match the active network. Exempt: `network_info`, `tool_describe`. - **SPV sync**: wallet tools call `resolve::ensure_spv_synced()` before operating — polls SPV status with 1s interval, 10min timeout. - **Backend dispatch**: tools reuse the app's `BackendTask` system via `dispatch::dispatch_task()` — creates a throwaway channel, calls `app_context.run_backend_task()`. diff --git a/src/app.rs b/src/app.rs index 20dd770b0..9710c6bba 100644 --- a/src/app.rs +++ b/src/app.rs @@ -798,8 +798,7 @@ impl AppState { self.zmq_listeners.insert(network, listener); } - // Persist the network choice (fast path — slow path persists in the - // BackendTask::SwitchNetwork handler, but fast path skips the handler). + // Persist the network choice. app_context .update_settings(RootScreenType::RootScreenNetworkChooser) .ok(); diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index bf0e471d8..6578d1e20 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -976,7 +976,7 @@ pub enum TaskError { // Network context errors // ────────────────────────────────────────────────────────────────────────── /// Creating a network context failed during a network switch. - #[error("Could not connect to {network:?}. Check your network configuration and retry.")] + #[error("Could not connect to {network}. Check your network configuration and retry.")] NetworkContextCreationFailed { network: Network }, } diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index 0c560c5cf..02a6e9943 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -104,8 +104,8 @@ pub enum BackendTask { /// (which includes DAPI discovery) runs off the UI thread. ReinitCoreClientAndSdk, /// Create a new network context and switch to it. - /// Intercepted by `AppState` — never dispatched to `AppContext::run_backend_task`. - /// When `start_spv` is true, SPV sync is started on the new context automatically. + /// Dispatched to `AppContext::run_backend_task`, which creates the new `AppContext` + /// and optionally starts SPV sync when `start_spv` is true. SwitchNetwork { network: Network, start_spv: bool, @@ -466,11 +466,6 @@ impl AppContext { self.egui_ctx().clone(), ) .ok_or(TaskError::NetworkContextCreationFailed { network })?; - // Persist the network choice so subsequent startups (GUI, CLI, - // MCP) all begin on the same network. - new_ctx - .update_settings(crate::ui::RootScreenType::RootScreenNetworkChooser) - .ok(); let spv_started = if start_spv { if new_ctx.core_backend_mode() != CoreBackendMode::Spv { diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index ef2bcaf4a..bca28f113 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -2156,9 +2156,11 @@ impl ScreenLike for NetworkChooserScreen { // Update config with new addresses let data_dir = &self.current_app_context().data_dir; - if let Ok(mut config) = Config::load_from(data_dir) - && let Some(mut network_cfg) = config.config_for_network(network).clone() - { + if let Ok(mut config) = Config::load_from(data_dir) { + let mut network_cfg = config + .config_for_network(network) + .clone() + .unwrap_or_default(); network_cfg.dapi_addresses = Some(addresses_csv); config.update_config_for_network(network, network_cfg.clone()); From 915173f646a9aa40f633b6691b7248b4416eeb77 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:30:09 +0200 Subject: [PATCH 04/10] =?UTF-8?q?fix(review):=20wave=202=20=E2=80=94=20ban?= =?UTF-8?q?ner=20lifecycle,=20async=20dispatch,=20macro=20completeness,=20?= =?UTF-8?q?dialog=20consistency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move network-switch progress banner from per-frame allocation to one-shot creation at switch initiation; clear via take_and_clear() on completion or error (CODE-001) - Replace synchronous reinit_core_client_and_sdk call in display_task_result with a deferred flag dispatched as BackendTask from the next ui() frame (PROJ-004) - Make set_ctx! macro exhaustive by adding a skip list for explicitly-handled variants; compiler now catches new Screen additions (CODE-003) - Wrap blocking AppContext::new() in tokio::task::block_in_place() inside the async SwitchNetwork handler (CODE-002) - Replace raw egui::Window fetch confirmation with ConfirmationDialog, matching SPV-clear and DB-clear dialogs on the same screen (CODE-009) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app.rs | 21 +++++---- src/backend_task/mod.rs | 28 +++++++---- src/ui/mod.rs | 25 ++++++++-- src/ui/network_chooser_screen.rs | 81 +++++++++++++++++--------------- 4 files changed, 95 insertions(+), 60 deletions(-) diff --git a/src/app.rs b/src/app.rs index 9710c6bba..f5e995063 100644 --- a/src/app.rs +++ b/src/app.rs @@ -13,7 +13,7 @@ use crate::database::Database; use crate::logging::initialize_logger; use crate::model::settings::Settings; use crate::spv::CoreBackendMode; -use crate::ui::components::{BannerHandle, MessageBanner}; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::contracts_documents::contracts_documents_screen::DocumentQueryScreen; use crate::ui::dashpay::{DashPayScreen, DashPaySubscreen, ProfileSearchScreen}; use crate::ui::dpns::dpns_contested_names_screen::{ @@ -136,6 +136,8 @@ pub struct AppState { /// Network whose context is being created asynchronously. While `Some`, /// the UI shows a progress banner and ignores further switch requests. network_switch_pending: Option, + /// Progress banner displayed while a network switch is in progress. + network_switch_banner: Option, #[allow(dead_code)] // Kept alive for the lifetime of the app zmq_listeners: BTreeMap, core_message_sender: egui_mpsc::SenderSync<(ZMQMessage, Network)>, @@ -558,6 +560,7 @@ impl AppState { connection_status, network_contexts, network_switch_pending: None, + network_switch_banner: None, zmq_listeners, core_message_sender, core_message_receiver, @@ -750,6 +753,11 @@ impl AppState { // (NetworkContextCreated) comes back through the task result channel // and is handled in update(). Same path used by MCP tools. self.network_switch_pending = Some(network); + self.network_switch_banner = Some(MessageBanner::set_global( + self.current_app_context().egui_ctx(), + format!("Connecting to {network:?}..."), + MessageType::Info, + )); let start_spv = self .current_app_context() .db @@ -1091,6 +1099,7 @@ impl App for AppState { } => { self.network_contexts.insert(network, context); self.network_switch_pending = None; + self.network_switch_banner.take_and_clear(); self.finalize_network_switch(network); } _ => { @@ -1109,6 +1118,7 @@ impl App for AppState { } TaskResult::Error(err @ TaskError::NetworkContextCreationFailed { .. }) => { self.network_switch_pending = None; + self.network_switch_banner.take_and_clear(); MessageBanner::set_global(ctx, err.to_string(), MessageType::Error); } TaskResult::Error(err) => { @@ -1132,15 +1142,6 @@ impl App for AppState { } } - // Show a progress banner while a network switch is in progress. - if let Some(pending_network) = self.network_switch_pending { - MessageBanner::set_global( - ctx, - format!("Connecting to {pending_network:?}..."), - MessageType::Info, - ); - } - // Schedule a periodic repaint every ~1 second so timed messages update // their countdown and other periodic UI elements stay current. // Throttled so we don't re-schedule on every frame during user interaction. diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index 02a6e9943..7cb1c0da2 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -456,15 +456,25 @@ impl AppContext { BackendTask::SwitchNetwork { network, start_spv } => { // Create a new AppContext for the target network, reusing shared // resources (db, subtasks, connection_status) from the current context. - let new_ctx = AppContext::new( - self.data_dir.clone(), - network, - self.db.clone(), - self.password_info.clone(), - self.subtasks.clone(), - self.connection_status.clone(), - self.egui_ctx().clone(), - ) + // Wrapped in block_in_place because AppContext::new() does DB init + // and file I/O which would block the async runtime. + let data_dir = self.data_dir.clone(); + let db = self.db.clone(); + let password_info = self.password_info.clone(); + let subtasks = self.subtasks.clone(); + let connection_status = self.connection_status.clone(); + let egui_ctx = self.egui_ctx().clone(); + let new_ctx = tokio::task::block_in_place(|| { + AppContext::new( + data_dir, + network, + db, + password_info, + subtasks, + connection_status, + egui_ctx, + ) + }) .ok_or(TaskError::NetworkContextCreationFailed { network })?; let spv_started = if start_spv { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index f2c6ce60c..54bcd9ccb 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -774,11 +774,15 @@ impl Screen { /// Assigns `app_context` for the majority of screen variants that simply /// store it as a field. Only screens with additional side-effects are /// handled in the explicit match arms below. + /// + /// Every `Screen` variant must appear in exactly one of the two lists + /// (`set` or `skip`) so the compiler catches new additions. macro_rules! set_ctx { - ($($variant:ident),+ $(,)?) => { + (set: $($variant:ident),+ $(,)?; skip: $($skip:ident),* $(,)?) => { match self { $(Screen::$variant(screen) => screen.app_context = app_context,)+ - _ => {} + // Handled by the explicit match above (side-effects + return). + $(Screen::$skip(_) => {},)* } } } @@ -861,7 +865,9 @@ impl Screen { } // Simple context assignment for all remaining screens. + // The `skip` list must exactly match the explicit match arms above. set_ctx!( + set: IdentitiesScreen, DPNSScreen, AddExistingIdentityScreen, @@ -909,7 +915,20 @@ impl Screen { DashPaySendPaymentScreen, DashPayContactInfoEditorScreen, DashPayQRGeneratorScreen, - DashPayProfileSearchScreen, + DashPayProfileSearchScreen; + skip: + NetworkChooserScreen, + AddNewWalletScreen, + TransferScreen, + WalletsBalancesScreen, + ImportMnemonicScreen, + WalletSendScreen, + MasternodeListDiffScreen, + AddressBalanceScreen, + DashPayScreen, + ShieldScreen, + ShieldedSendScreen, + UnshieldCreditsScreen, ); } } diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index bca28f113..946904f7f 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -117,7 +117,10 @@ pub struct NetworkChooserScreen { /// Progress banner shown while reinit runs in the background. reinit_banner: Option, discovery_in_progress: bool, - show_fetch_confirmation: bool, + fetch_confirmation_dialog: Option, + /// Set when DAPI discovery completes and an SDK reinit is needed. + /// Dispatched as a `BackendTask` from the next `ui()` call. + pending_reinit_after_discovery: bool, } impl NetworkChooserScreen { @@ -206,7 +209,8 @@ impl NetworkChooserScreen { config_save_failed: false, reinit_banner: None, discovery_in_progress: false, - show_fetch_confirmation: false, + fetch_confirmation_dialog: None, + pending_reinit_after_discovery: false, } } @@ -788,7 +792,16 @@ impl NetworkChooserScreen { ); if clicked { if dapi_total > 0 { - self.show_fetch_confirmation = true; + let message = format!( + "This will fetch a fresh list of DAPI nodes, replacing your current {} \ + configured addresses in the config file.", + dapi_total + ); + self.fetch_confirmation_dialog = Some( + ConfirmationDialog::new("Update Node Addresses?", message) + .confirm_text(Some("Fetch")) + .cancel_text(Some("Cancel")), + ); } else { self.discovery_in_progress = true; app_action = AppAction::BackendTask( @@ -802,33 +815,8 @@ impl NetworkChooserScreen { }); // Fetch confirmation dialog - if self.show_fetch_confirmation { - egui::Window::new("Update Node Addresses?") - .collapsible(false) - .resizable(false) - .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) - .show(ui.ctx(), |ui| { - ui.label(format!( - "This will fetch a fresh list of DAPI nodes, replacing your current {} \ - configured addresses in the config file.", - dapi_total - )); - ui.add_space(12.0); - ui.horizontal(|ui| { - if ui.button("Cancel").clicked() { - self.show_fetch_confirmation = false; - } - if ui.button("Fetch").clicked() { - self.show_fetch_confirmation = false; - self.discovery_in_progress = true; - app_action = AppAction::BackendTask( - BackendTask::DiscoverDapiNodes { - network: self.current_network, - }, - ); - } - }); - }); + if self.fetch_confirmation_dialog.is_some() { + app_action |= self.show_fetch_confirmation(ui); } }); @@ -1732,6 +1720,23 @@ impl NetworkChooserScreen { action } + fn show_fetch_confirmation(&mut self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + if let Some(dialog) = self.fetch_confirmation_dialog.as_mut() { + let response = dialog.show(ui); + if let Some(result) = response.inner.dialog_response { + self.fetch_confirmation_dialog = None; + if matches!(result, ConfirmationStatus::Confirmed) { + self.discovery_in_progress = true; + action = AppAction::BackendTask(BackendTask::DiscoverDapiNodes { + network: self.current_network, + }); + } + } + } + action + } + fn show_spv_clear_confirmation(&mut self, ui: &mut Ui) -> AppAction { if let Some(dialog) = self.spv_clear_dialog.as_mut() { let response = dialog.show(ui); @@ -2096,6 +2101,12 @@ impl ScreenLike for NetworkChooserScreen { .inner }); + // Dispatch deferred SDK reinit after DAPI discovery + if self.pending_reinit_after_discovery { + self.pending_reinit_after_discovery = false; + action |= AppAction::BackendTask(BackendTask::ReinitCoreClientAndSdk); + } + // Recheck both network status every 3 seconds let recheck_time = Duration::from_secs(3); if action == AppAction::None { @@ -2168,18 +2179,12 @@ impl ScreenLike for NetworkChooserScreen { tracing::error!("Failed to save config after DAPI discovery: {e}"); } - // Update in-memory config and reinit SDK if context exists + // Update in-memory config and schedule async SDK reinit if let Some(app_context) = self.context_for_network(network) { if let Ok(mut cfg_lock) = app_context.config.write() { *cfg_lock = network_cfg; } - if let Err(e) = Arc::clone(app_context).reinit_core_client_and_sdk() { - tracing::error!( - "Failed to reinit SDK after DAPI discovery for {:?}: {}", - network, - e - ); - } + self.pending_reinit_after_discovery = true; } MessageBanner::set_global( From 9d93444858c4842bf31e2e9ed35508e7bf2469ea Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:51:34 +0200 Subject: [PATCH 05/10] fix(context): use create_core_rpc_client() in reinit to preserve cookie auth Replace the direct Client::new(Auth::UserPass(...)) call in reinit_core_client_and_sdk() with Self::create_core_rpc_client(), which tries cookie authentication first and falls back to user/pass. Fixes setups that rely on .cookie auth being silently bypassed on reinit. Co-Authored-By: Claude Sonnet 4.6 --- src/context/mod.rs | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/context/mod.rs b/src/context/mod.rs index cdf354af8..66f59432a 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -576,18 +576,9 @@ impl AppContext { // Note: developer_mode is now global and managed separately - // 2. Rebuild the RPC client with the new password + // 2. Rebuild the RPC client with the new credentials (cookie auth first, then user/pass). let addr = format!("http://{}:{}", cfg.rpc_host(), cfg.rpc_port(self.network)); - let new_client = Client::new( - &addr, - Auth::UserPass( - cfg.core_rpc_user.clone().unwrap_or_default(), - cfg.core_rpc_password.clone().unwrap_or_default(), - ), - ) - .map_err(|e| TaskError::RpcProviderCreationFailed { - detail: e.to_string(), - })?; + let new_client = Self::create_core_rpc_client(&addr, self.network, &cfg.devnet_name, &cfg)?; // 3. Parse DAPI addresses from config and rebuild the SDK let address_list = match &cfg.dapi_addresses { From 6f4002f67643b1d706803dacbe1a27d1c9ac21b8 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:03:31 +0200 Subject: [PATCH 06/10] =?UTF-8?q?fix(review):=20wave=20A=20=E2=80=94=20net?= =?UTF-8?q?work=20fallback,=20switch=20guard,=20init=20safety,=20path=20sa?= =?UTF-8?q?nitization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use chosen_network (not saved_network) for NetworkChooserScreen so the UI reflects the actual fallback network after init failure - Block ALL overlapping network switches, not just duplicates to the same network, preventing state corruption from out-of-order completion - Use OnceCell::const_new() in new_shared() — the pre-filled guard was misleading since Shared mode never enters the init path - Move core_backend_mode store/persist after provider bind succeeds so a failed bind does not leave the mode and provider out of sync - Catch and sanitize init_app_context() errors in MCP ctx() to avoid leaking filesystem paths to MCP callers Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app.rs | 11 ++++++++--- src/context/mod.rs | 25 ++++++++++++------------- src/mcp/server.rs | 7 +++++-- 3 files changed, 25 insertions(+), 18 deletions(-) diff --git a/src/app.rs b/src/app.rs index f5e995063..6dfb2955d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -399,7 +399,7 @@ impl AppState { let dashpay_profile_search_screen = ProfileSearchScreen::new(active_context.clone()); let network_chooser_screen = - NetworkChooserScreen::new(&network_contexts, saved_network, overwrite_dash_conf); + NetworkChooserScreen::new(&network_contexts, chosen_network, overwrite_dash_conf); let masternode_list_diff_screen = MasternodeListDiffScreen::new(&active_context); @@ -738,8 +738,13 @@ impl AppState { } pub fn change_network(&mut self, network: Network) { - // Ignore if we're already switching to this network. - if self.network_switch_pending == Some(network) { + // Block any new switch while one is already in progress. + if self.network_switch_pending.is_some() { + tracing::debug!( + "Ignoring network switch to {:?} — switch to {:?} already pending", + network, + self.network_switch_pending + ); return; } diff --git a/src/context/mod.rs b/src/context/mod.rs index 66f59432a..1aeee5529 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -450,20 +450,9 @@ impl AppContext { } fn set_core_backend_mode_inner(self: &Arc, mode: CoreBackendMode, persist: bool) { - self.core_backend_mode - .store(mode.as_u8(), Ordering::Relaxed); - - if persist { - // Persist the mode to the database (hold the guard to ensure cache invalidation) - let _guard = self.invalidate_settings_cache(); - if let Err(e) = self.db.update_core_backend_mode(mode.as_u8()) { - tracing::error!("Failed to persist core backend mode: {}", e); - } - } - // Switch SDK context provider to match the selected backend. - // Early returns are defensive: if code is added after this match, a failed - // bind should not proceed with a stale provider. + // Only store/persist the mode after binding succeeds — otherwise the app + // would report the new mode while still wired to the old provider. #[allow(clippy::needless_return)] match mode { CoreBackendMode::Spv => { @@ -489,6 +478,16 @@ impl AppContext { } } } + + self.core_backend_mode + .store(mode.as_u8(), Ordering::Relaxed); + + if persist { + let _guard = self.invalidate_settings_cache(); + if let Err(e) = self.db.update_core_backend_mode(mode.as_u8()) { + tracing::error!("Failed to persist core backend mode: {}", e); + } + } } /// Get the cached fee multiplier permille (1000 = 1x, 2000 = 2x) diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 8be0c3546..2159fcfe6 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -69,7 +69,7 @@ impl DashMcpService { Self { ctx: ContextHolder::Shared(app_context), #[cfg(feature = "cli")] - init_guard: Arc::new(tokio::sync::OnceCell::const_new_with(())), + init_guard: Arc::new(tokio::sync::OnceCell::const_new()), tool_router: Self::tool_router(), } } @@ -94,7 +94,10 @@ impl DashMcpService { let ctx_holder = self.ctx.clone(); self.init_guard .get_or_try_init(|| async { - let app_context = init_app_context().await?; + let app_context = init_app_context().await.map_err(|e| { + tracing::error!("MCP context initialization failed: {e}"); + McpError::internal_error("Failed to initialize application context", None) + })?; ctx_holder.store(app_context); Ok::<(), McpError>(()) }) From 1e314159899d403359d1a60c08eaf7ade0816e1e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:04:04 +0200 Subject: [PATCH 07/10] =?UTF-8?q?fix(review):=20wave=20B=20=E2=80=94=20tok?= =?UTF-8?q?en=20name=20escape,=20address=20logging,=20error=20source,=20SP?= =?UTF-8?q?V=20status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Escape control characters in InvalidTokenNameCharacter display to prevent unreadable banners from tab/newline-injected token names - Log warning when PlatformAddress re-encoding fails instead of silently dropping entries from the balances map - Add diagnostic detail field to NetworkContextCreationFailed for Debug output (user-facing message unchanged) - Check actual SPV status via ConnectionStatus on no-op network switch instead of hardcoding spv_started: true Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend_task/error.rs | 12 +++++++++--- src/backend_task/mod.rs | 5 ++++- .../wallet/fetch_platform_address_balances.rs | 16 +++++++++++----- src/mcp/tools/network.rs | 3 ++- 4 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index 6578d1e20..65aceef62 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -390,8 +390,9 @@ pub enum TaskError { /// The token name contains whitespace or control characters. #[error( - "The token name \"{token_name}\" in {form} contains invalid characters. \ - Token names must not include spaces or control characters. Please rename and try again." + "The token name \"{}\" in {form} contains invalid characters. \ + Token names must not include spaces or control characters. Please rename and try again.", + escape_token_name(token_name) )] InvalidTokenNameCharacter { form: String, @@ -977,7 +978,12 @@ pub enum TaskError { // ────────────────────────────────────────────────────────────────────────── /// Creating a network context failed during a network switch. #[error("Could not connect to {network}. Check your network configuration and retry.")] - NetworkContextCreationFailed { network: Network }, + NetworkContextCreationFailed { network: Network, detail: String }, +} + +/// Escapes control characters in a token name for safe display in error messages. +fn escape_token_name(name: &str) -> String { + name.chars().filter(|c| !c.is_control()).collect() } /// Returns `true` when a `dashcore_rpc::Error` wraps an HTTP 401 response, diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index 7cb1c0da2..e068a9e14 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -475,7 +475,10 @@ impl AppContext { egui_ctx, ) }) - .ok_or(TaskError::NetworkContextCreationFailed { network })?; + .ok_or(TaskError::NetworkContextCreationFailed { + network, + detail: "AppContext::new() returned None".into(), + })?; let spv_started = if start_spv { if new_ctx.core_backend_mode() != CoreBackendMode::Spv { diff --git a/src/backend_task/wallet/fetch_platform_address_balances.rs b/src/backend_task/wallet/fetch_platform_address_balances.rs index d1fb8f32b..c513a68b0 100644 --- a/src/backend_task/wallet/fetch_platform_address_balances.rs +++ b/src/backend_task/wallet/fetch_platform_address_balances.rs @@ -170,11 +170,17 @@ impl AppContext { wallet .platform_address_info .iter() - .filter_map(|(addr, info)| { - PlatformAddress::try_from(addr.clone()) - .ok() - .map(|pa| (pa, (info.balance, info.nonce))) - }) + .filter_map( + |(addr, info)| match PlatformAddress::try_from(addr.clone()) { + Ok(pa) => Some((pa, (info.balance, info.nonce))), + Err(e) => { + tracing::warn!( + "Skipping platform address that could not be re-encoded: {e}" + ); + None + } + }, + ) .collect() }; diff --git a/src/mcp/tools/network.rs b/src/mcp/tools/network.rs index 9b4eddc0c..371aad0b0 100644 --- a/src/mcp/tools/network.rs +++ b/src/mcp/tools/network.rs @@ -225,9 +225,10 @@ impl AsyncTool for NetworkSwitch { // Already on the target network — no-op. if ctx.network() == target { + let spv_running = ctx.connection_status().spv_status().is_active(); return Ok(NetworkSwitchOutput { active: network_display_name(target).to_owned(), - spv_started: true, + spv_started: spv_running, }); } From 6238ae8a68241210bab761c92d38324bd6693765 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:08:47 +0200 Subject: [PATCH 08/10] =?UTF-8?q?fix(review):=20wave=20C=20=E2=80=94=20Fea?= =?UTF-8?q?tureGate=20consistency,=20wallet=20state=20cleanup,=20stale=20s?= =?UTF-8?q?creen=20handling,=20address=20network?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace direct is_developer_mode() calls with FeatureGate::DeveloperMode pattern in wallets_screen for UI consistency - Add reset_transient_state() to WalletsBalancesScreen to clear pending operations on network switch (platform balance refresh, unlock flags, asset lock search, core wallet dialog) - Clear wallet references in WalletSendScreen, SingleKeyWalletSendScreen, and CreateAssetLockScreen on network switch to prevent stale wallet Arcs from the previous context - Add network field to PlatformAddressBalances result so the display handler can verify the result matches the current network, discarding stale results Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend_task/mod.rs | 2 ++ .../wallet/fetch_platform_address_balances.rs | 1 + src/ui/mod.rs | 19 ++++++++++-- src/ui/wallets/create_asset_lock_screen.rs | 2 +- src/ui/wallets/wallets_screen/mod.rs | 29 +++++++++++++++++-- 5 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index e068a9e14..7a88a3e41 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -212,6 +212,8 @@ pub enum BackendTaskSuccessResult { seed_hash: WalletSeedHash, /// Map of platform address to (balance, nonce) balances: BTreeMap, + /// Network the balances were fetched from + network: Network, }, /// Platform credits transferred between addresses PlatformCreditsTransferred { diff --git a/src/backend_task/wallet/fetch_platform_address_balances.rs b/src/backend_task/wallet/fetch_platform_address_balances.rs index c513a68b0..86a4ac217 100644 --- a/src/backend_task/wallet/fetch_platform_address_balances.rs +++ b/src/backend_task/wallet/fetch_platform_address_balances.rs @@ -194,6 +194,7 @@ impl AppContext { Ok(BackendTaskSuccessResult::PlatformAddressBalances { seed_hash, balances, + network: self.network(), }) } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 54bcd9ccb..bcc2b8ce6 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -811,6 +811,7 @@ impl Screen { screen.reset_pending_list_state(); screen.update_selected_wallet_for_network(); screen.invalidate_address_inputs(); + screen.reset_transient_state(); return; } Screen::ImportMnemonicScreen(screen) => { @@ -821,6 +822,20 @@ impl Screen { Screen::WalletSendScreen(screen) => { screen.app_context = app_context; screen.invalidate_address_input(); + // Clear wallet reference — it belongs to the old network + screen.selected_wallet = None; + return; + } + Screen::SingleKeyWalletSendScreen(screen) => { + screen.app_context = app_context; + // Clear wallet reference — it belongs to the old network + screen.selected_wallet = None; + return; + } + Screen::CreateAssetLockScreen(screen) => { + screen.app_context = app_context; + // Clear wallet reference — it belongs to the old network + screen.selected_wallet = None; return; } Screen::MasternodeListDiffScreen(screen) => { @@ -885,7 +900,6 @@ impl Screen { DocumentActionScreen, GroupActionsScreen, TopUpIdentityScreen, - SingleKeyWalletSendScreen, ProofLogScreen, AddContractsScreen, ProofVisualizerScreen, @@ -908,7 +922,6 @@ impl Screen { PurchaseTokenScreen, SetTokenPriceScreen, AssetLockDetailScreen, - CreateAssetLockScreen, DashPayAddContactScreen, DashPayContactDetailsScreen, DashPayContactProfileViewerScreen, @@ -923,6 +936,8 @@ impl Screen { WalletsBalancesScreen, ImportMnemonicScreen, WalletSendScreen, + SingleKeyWalletSendScreen, + CreateAssetLockScreen, MasternodeListDiffScreen, AddressBalanceScreen, DashPayScreen, diff --git a/src/ui/wallets/create_asset_lock_screen.rs b/src/ui/wallets/create_asset_lock_screen.rs index 9bd67ff26..46ab006ef 100644 --- a/src/ui/wallets/create_asset_lock_screen.rs +++ b/src/ui/wallets/create_asset_lock_screen.rs @@ -34,7 +34,7 @@ enum AssetLockPurpose { pub struct CreateAssetLockScreen { pub wallet: Arc>, - selected_wallet: Option>>, + pub(crate) selected_wallet: Option>>, pub app_context: Arc, password_input: PasswordInput, // Asset lock creation fields diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index d0fe6c576..24caa78bc 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -470,6 +470,20 @@ impl WalletsBalancesScreen { self.pending_list_is_single_key = false; } + /// Clear all transient request/pending state that could fire against the + /// wrong context after a network switch. + pub(crate) fn reset_transient_state(&mut self) { + self.pending_platform_balance_refresh = None; + self.pending_refresh_after_unlock = false; + self.pending_asset_lock_search_after_unlock = false; + self.pending_wallet_refresh_on_switch = false; + self.pending_core_wallet_seed_hash = None; + self.pending_core_wallet_options = None; + self.core_wallet_dialog = None; + self.refreshing = false; + self.asset_lock_search_banner.take_and_clear(); + } + /// Reset all cached AddressInput widgets so they pick up the new network. pub(crate) fn invalidate_address_inputs(&mut self) { self.mine_dialog.address_input = None; @@ -1089,7 +1103,7 @@ impl WalletsBalancesScreen { } // Dev-mode buttons: right-aligned, filling all remaining space - if self.app_context.is_developer_mode() { + if FeatureGate::DeveloperMode.is_available(&self.app_context) { let remaining = ui.available_width(); ui.allocate_ui_with_layout( egui::vec2(remaining, ui.min_size().y), @@ -2009,7 +2023,7 @@ impl WalletsBalancesScreen { .color(DashColors::text_primary(dark_mode)) .size(25.0), ); - if self.app_context.is_developer_mode() { + if FeatureGate::DeveloperMode.is_available(&self.app_context) { ui.label( RichText::new("[DEV]") .color(DashColors::text_secondary(dark_mode)) @@ -2874,15 +2888,24 @@ impl ScreenLike for WalletsBalancesScreen { crate::ui::BackendTaskSuccessResult::PlatformAddressBalances { seed_hash, balances, + network, } => { self.refreshing = false; + // Skip stale results from a different network + if network != self.app_context.network { + tracing::warn!( + result_network = ?network, + current_network = ?self.app_context.network, + "Discarding PlatformAddressBalances from a previous network" + ); + return; + } // Update wallet's platform_address_info if this is for the selected wallet if let Some(selected) = &self.selected_wallet && let Ok(mut wallet) = selected.write() && wallet.seed_hash() == seed_hash { // Convert PlatformAddress back to Core Address for wallet storage - let network = self.app_context.network(); for (platform_addr, (balance, nonce)) in balances { let core_addr = platform_addr.to_address_with_network(network); wallet.set_platform_address_info(core_addr, balance, nonce); From f06b4e25397668c8b546d0ef947fbcbdd5a0c419 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:11:30 +0200 Subject: [PATCH 09/10] fix(test): update token name test to expect escaped output Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend_task/error.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index 65aceef62..7f448765a 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -2488,8 +2488,8 @@ mod tests { let err = TaskError::from(sdk_err); let msg = err.to_string(); assert!( - msg.contains("bad\tname"), - "Expected token name in message, got: {msg}" + msg.contains("badname"), + "Expected escaped token name in message, got: {msg}" ); assert!( msg.contains("rename"), From f698f2b1f0d2a97fd58d70d09e7315333df2fd18 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:35:10 +0200 Subject: [PATCH 10/10] chore: remove unneeded generated docs --- .../ux-spec.md | 396 -------------- .../wireframe.html | 515 ------------------ 2 files changed, 911 deletions(-) delete mode 100644 docs/ai-design/2026-03-30-dapi-discovery-button/ux-spec.md delete mode 100644 docs/ai-design/2026-03-30-dapi-discovery-button/wireframe.html diff --git a/docs/ai-design/2026-03-30-dapi-discovery-button/ux-spec.md b/docs/ai-design/2026-03-30-dapi-discovery-button/ux-spec.md deleted file mode 100644 index 590c3de17..000000000 --- a/docs/ai-design/2026-03-30-dapi-discovery-button/ux-spec.md +++ /dev/null @@ -1,396 +0,0 @@ -# UX Specification: Manual DAPI Node Discovery Button - -**Date**: 2026-03-30 -**Feature**: User-triggered node address discovery in Network Settings -**Status**: Draft - ---- - -## 1. Problem Statement - -Dash Evo Tool connects to the Dash Platform network through DAPI nodes (masternodes). Currently, node addresses are configured in a `.env` file and, for Mainnet/Testnet, discovered automatically at startup from a DCG-operated HTTPS endpoint. The app is moving to a manual, user-triggered discovery model where: - -- No automatic discovery at startup -- the app always uses addresses from its config -- A button in the Network Settings screen lets users fetch current node addresses on demand -- Users understand they are fetching from a centralized service (DCG), not from the blockchain itself - -## 2. Personas and Walkthrough - -### Alex (Everyday User) - -Alex opens Network Settings because the app said it could not connect to the network. Alex sees the Connection Settings card and notices a message near the empty node address area: "No node addresses configured." Next to it is a clearly labeled button. Alex clicks it, sees a brief spinner, and the field fills with addresses. Alex clicks Save and the app connects. Alex never needs to understand what DAPI means or where the addresses came from. - -**Key need**: A single clear action that fixes the "can't connect" problem. No jargon. - -### Priya (Power User) - -Priya opens Network Settings to refresh her node list after a network upgrade. She sees the "Fetch Node List" button and the info icon next to it. She hovers the info icon and reads that addresses come from a DCG-operated service. She clicks the button. A confirmation dialog appears because she already has addresses configured -- it tells her the current count and the count about to be fetched. She confirms, reviews the new addresses in the text field, and saves. - -**Key need**: Transparency about what is happening, confirmation before overwriting, ability to review and edit the result. - -### Jordan (Platform Developer) - -Jordan is on a Devnet tab. The Fetch Node List button is not visible -- Jordan knows devnet addresses must be entered manually. On Testnet, Jordan uses the button occasionally but is just as likely to paste addresses directly. Jordan appreciates that the button does not auto-save -- it populates the field and lets Jordan edit before committing. - -**Key need**: Button stays out of the way on devnets. Does not auto-save; just populates. - -## 3. Design - -### 3.1 Placement - -The discovery button lives in a new **"Node Addresses"** section within the existing **Connection Settings** card, placed immediately below the Network selector row (and SPV warning, if shown) and above the Core RPC Password section. This section is visible for **all networks** but the button is only available for Mainnet and Testnet. - -``` -Connection Settings Card -+-------------------------------------------------------+ -| Connection Type: [SPV Client v] (dev mode only) | -| Network: [Mainnet v] | -| | -| --- Node Addresses --- | -| [multiline text field with current DAPI addresses] | -| [Fetch Node List] (i) | -| ^secondary btn ^info icon with tooltip | -| | -| --- Core RPC Password --- (RPC mode only) | -| [password field] [Save] | -+-------------------------------------------------------+ -``` - -**Layout details:** -- Section label: "Node Addresses" -- rendered as a bold subheading (`RichText::new(...).strong().color(text_primary)`) -- Text field: multiline `TextEdit` showing the current `dapi_addresses` value (comma-separated URLs). Editable. Approximately 3-4 lines tall. Uses `styled_text_edit_multiline()` with standard input strokes. -- Below the text field, a horizontal row containing the button and info icon -- Spacing follows existing patterns: `add_space(12.0)` before section, `add_space(8.0)` between label and field, `add_space(8.0)` between field and button row - -### 3.2 Button Design - -| Property | Value | -|----------|-------| -| **Label** | "Fetch Node List" | -| **Variant** | Secondary (outlined, `StyledButton` `ButtonVariant::Secondary`) | -| **Size** | Medium | -| **Icon** | None (egui icon support is limited; keep it text-only) | -| **Min width** | 160px | - -**Rationale for "Fetch Node List"**: -- Avoids "DAPI" jargon -- Alex does not know what DAPI is -- "Fetch" communicates a network operation (important for trust/timing expectations) -- "Node List" is more concrete than "Discover Nodes" -- it describes what you get -- Secondary variant ensures it does not compete visually with the primary "Save" button in the password section - -**Visibility rules:** -- **Mainnet, Testnet**: Button visible and enabled -- **Devnet, Regtest**: Button hidden. In its place, a small caption: "Enter node addresses manually for this network." - -### 3.3 Info Icon and Trust Disclosure - -To the right of the button, render a small info label `(i)` with an `info_tooltip()` (uses the Help cursor per UX design patterns). - -**Tooltip text:** - -> Fetches the current list of available nodes from a service operated by Dash Core Group (DCG). This is a convenience service over HTTPS -- it does not access the blockchain directly. Platform proofs are verified independently, so incorrect node addresses cannot forge data, but they could prevent the app from connecting. - -**Rationale**: Progressive disclosure. Alex never reads it. Priya hovers once, understands the trust model, and is satisfied. Jordan already knows but appreciates the precision. - -### 3.4 Empty State - -When the text field is empty AND the current network is Mainnet or Testnet, show a hint message below the text field (above the button row): - -``` -[text field -- empty, showing placeholder "No node addresses configured"] - - Use "Fetch Node List" to get the current addresses, or enter them manually. - -[Fetch Node List] (i) -``` - -The hint text is rendered in `DashColors::text_secondary(dark_mode)`, `Typography::SCALE_SM`, italics. It disappears once the field has content. - -For Devnet/Regtest with an empty field: - -``` -[text field -- empty, showing placeholder "No node addresses configured"] - - Enter node addresses for this network (comma-separated URLs). -``` - -### 3.5 Loading State - -When the user clicks "Fetch Node List": - -1. Button text changes to "Fetching..." with a spinner (`ui.spinner()`) to the left of the button text -2. Button is disabled (prevents double-submit per UX patterns) -3. Text field remains visible and read-only during the fetch (not disabled -- just non-editable, so the user can see existing content) -4. Expected duration is 1-10 seconds per the discovery module's 10-second timeout - -**Implementation note**: This should be dispatched as a `BackendTask` to avoid blocking the UI thread. The screen stores a `discovery_in_progress: bool` flag. The discovery result arrives through the standard `display_task_result()` path. - -### 3.6 Success State (No Existing Addresses) - -When the field was empty before the fetch: - -1. Text field is populated with the fetched addresses (comma-separated) -2. Button returns to default state -3. A success banner appears: "Found {count} node addresses. Review them below and save your settings." -4. The field is **not** auto-saved -- the user must click the existing Save mechanism (or the addresses are saved when the network config is saved) - -**Important**: The fetched addresses populate the field but do not persist until the user explicitly saves. This gives all personas a chance to review and edit. - -### 3.7 Success State (Existing Addresses -- Confirmation Dialog) - -When the field already contains addresses and the user clicks "Fetch Node List": - -1. A `ConfirmationDialog` appears before the fetch begins -2. Dialog content: - -``` -+---------------------------------------------------+ -| Update Node Addresses? | -| | -| This will replace your current node addresses | -| with a fresh list fetched from the Dash network | -| service. | -| | -| You currently have {N} addresses configured. | -| You can review and edit the new list before | -| saving. | -| | -| [Cancel] [Fetch] | -+---------------------------------------------------+ -``` - -| Dialog property | Value | -|-----------------|-------| -| Title | "Update Node Addresses?" | -| Confirm label | "Fetch" | -| Cancel label | "Cancel" | -| Danger mode | No (this is not destructive -- old addresses are only replaced in the field, not saved) | -| Escape/X | Cancels | - -**After confirmation**, the fetch proceeds and the field is populated with new addresses. The old addresses are replaced in the field but not persisted until Save. - -### 3.8 Error State - -When the fetch fails: - -1. Button returns to default state ("Fetch Node List", enabled) -2. An error banner appears via `MessageBanner::set_global()`: - - **Timeout**: "Node list fetch timed out. Check your internet connection and try again." - - **Network error**: "Could not fetch the node list. Check your internet connection and try again." - - **No results**: "No available nodes were found. The network may be temporarily unavailable -- try again later." - - **Other**: "Could not fetch the node list. Try again, or enter node addresses manually." -3. Existing addresses in the field are **not** modified on error - -These messages align with the existing `DapiDiscoveryError` variants in `src/dapi_discovery.rs`. - -### 3.9 Interaction with Save - -The node addresses field needs a Save mechanism. Two options: - -**Recommended approach**: Add the node addresses field to the existing config save flow. When the Core RPC Password "Save" button is clicked (or a new dedicated "Save" is added for this section), persist the current contents of the node addresses field to the `.env` config file using `Config::update_config_for_network()`. - -If the Node Addresses section is above the Core RPC Password section, adding a small "Save" button on the same row as the Fetch button keeps the interaction local: - -``` -[Fetch Node List] (i) [Save] -``` - -The Save button here: -- Uses `StyledButton` Secondary variant, same size as Fetch -- Saves the current text field content to the config file -- Shows success/error via `MessageBanner` -- Is always enabled (even if the field has not changed -- simpler, and egui does not trivially track dirty state) - -### 3.10 State Diagram - -``` - +------------------+ - | Default State | - | Field: current | - | Button: enabled | - +--------+---------+ - | - User clicks "Fetch Node List" - | - +--------v---------+ - yes | Field has | no - +--------+ existing addrs? +--------+ - | +------------------+ | - v v - +--------+---------+ +-----------+--------+ - | Confirmation | Cancel | Fetching State | - | Dialog shown +---+ | Button: disabled | - +--------+---------+ | | Spinner shown | - | | +---------+----------+ - Confirm | | - | | +--------v---------+ - v | yes | Fetch result? | no - +--------+---------+ | +-----+ +-----+ - | Fetching State | | | +------------------+ | - | Button: disabled | | v v - | Spinner shown | | +-+-------------+ +-----------+-+ - +--------+---------+ | | Success | | Error | - | | | Field updated | | Field stays | - | | | Banner: info | | Banner: err | - +--------v---------+ | +------+--------+ +------+------+ - | Fetch result? | | | | - +--+------------+--+ | v v - yes no | +----+--------------------+----+ - | | | | Default State | - v v +-->| (user can now Save) | - Success Error +------------------------------+ -``` - -## 4. Component Specification - -### 4.1 Node Address Text Field - -``` -Component: NodeAddressField -Purpose: Display and edit the comma-separated list of DAPI node URLs -Type: multiline TextEdit (styled_text_edit_multiline) -States: - - default: editable, shows current addresses - - empty: shows placeholder "No node addresses configured" - - read-only-during-fetch: text visible but not editable -Responsive: full available width, 3-4 lines height (approximately 80px) -Accessibility: - - Tab-focusable - - Placeholder text visible when empty -``` - -### 4.2 Fetch Node List Button - -``` -Component: StyledButton (Secondary variant) -Label: "Fetch Node List" -States: - - default: outlined secondary button, enabled - - hover: pointing hand cursor (automatic from StyledButton) - - loading: text changes to "Fetching...", disabled, spinner adjacent - - hidden: on Devnet/Regtest networks -Min width: 160px -Accessibility: - - Tab-focusable - - disabled_tooltip when loading: "Fetching node addresses..." -``` - -### 4.3 Info Icon - -``` -Component: label "(i)" with info_tooltip() -Purpose: Trust disclosure for the discovery service -States: - - default: subtle text in text_secondary color - - hover: Help cursor, tooltip shown -Accessibility: - - info_tooltip provides the help cursor automatically -``` - -### 4.4 Save Button (Node Addresses) - -``` -Component: StyledButton (Secondary variant) -Label: "Save" -Purpose: Persist node addresses to config file -States: - - default: enabled - - hover: pointing hand cursor -Placement: right-aligned on the button row -``` - -### 4.5 Confirmation Dialog - -``` -Component: ConfirmationDialog (existing pattern) -Title: "Update Node Addresses?" -Body: explains what will happen, shows current address count -Confirm label: "Fetch" -Cancel label: "Cancel" -Danger mode: false -Trigger: clicking Fetch when field already has content -``` - -## 5. Screen State Additions - -New fields on `NetworkChooserScreen`: - -```rust -/// Current text in the node addresses field per network -node_addresses_text: HashMap, - -/// Whether a discovery fetch is in progress -discovery_in_progress: bool, - -/// Confirmation dialog for overwriting existing addresses -discovery_confirm_dialog: Option, -``` - -On construction, `node_addresses_text` is populated from `Config::load_from()` for each network's `dapi_addresses` field. - -## 6. Backend Task - -A new `SystemTask` variant handles the async discovery: - -```rust -SystemTask::DiscoverDapiNodes { network: Network } -``` - -This calls the existing `try_discover_nodes()` from `src/dapi_discovery.rs` (the async variant, not the sync one with fallback). The result is returned as a new `BackendTaskSuccessResult` variant: - -```rust -BackendTaskSuccessResult::DapiNodesDiscovered { - network: Network, - addresses: Vec, -} -``` - -The screen's `display_task_result()` handler populates `node_addresses_text[network]` with the comma-separated result and shows the success banner. - -## 7. Accessibility - -| Requirement | Implementation | -|-------------|----------------| -| Keyboard navigation | All elements (text field, buttons, info icon) are Tab-focusable in layout order | -| Focus indicator | Standard egui focus ring (BORDER_WIDTH_THICK per UX patterns) | -| Tooltips | info_tooltip on (i) icon, disabled_tooltip on button during fetch | -| Screen readers | egui has limited a11y; no additional ARIA annotations possible | -| Color contrast | All text meets WCAG AA (inherited from DashColors theme system) | -| Click targets | Buttons use StyledButton which meets WCAG AA minimum targets | - -## 8. Responsive Behavior - -The Node Addresses section uses `ui.available_width()` for the text field (full width). The button row is horizontal with the Fetch button left-aligned and Save button right-aligned. On narrow windows, buttons wrap naturally via egui's horizontal layout (they will stack if space is insufficient, which is acceptable for this secondary feature). - -## 9. Edge Cases - -| Case | Behavior | -|------|----------| -| User clicks Fetch, then switches network tab during fetch | The result is tagged with the network it was fetched for. If the user has switched networks, the result populates `node_addresses_text[original_network]` silently. The banner still appears. | -| Fetch returns identical addresses to what was already configured | Treat as success. Field is updated (same content). Banner shows the count. | -| User edits the field manually, then clicks Fetch | Confirmation dialog appears (field has content). Fetch replaces the manual edits in the field. | -| User clicks Fetch, gets results, then clicks Fetch again without saving | Confirmation dialog appears (field has content from the first fetch). Second fetch replaces. | -| Config file is read-only or missing | Save button shows error banner: "Could not save settings. Check that the application folder is writable and retry." (matches existing `ConfigError::SaveError` pattern). | -| Discovery returns hundreds of addresses | All addresses populate the field. The multiline TextEdit scrolls. User can edit to trim if desired. | - -## 10. What This Spec Does NOT Cover - -- Automatic migration of old hardcoded addresses (handled separately in `config.rs`) -- Changes to the startup discovery flow (separate task) -- The `.env` file format or config parsing (existing code in `Config`) -- MCP/CLI exposure of discovery (out of scope for UI spec) - -## 11. Implementation Checklist - -- [ ] Add `node_addresses_text: HashMap` and related fields to `NetworkChooserScreen` -- [ ] Render the Node Addresses section in `render_network_table()` between Network selector and Core RPC Password -- [ ] Implement `StyledButton::secondary()` variant usage for Fetch and Save buttons -- [ ] Add `SystemTask::DiscoverDapiNodes` backend task variant -- [ ] Add `BackendTaskSuccessResult::DapiNodesDiscovered` result variant -- [ ] Wire `display_task_result()` to populate the text field on success -- [ ] Add `ConfirmationDialog` for overwrite confirmation -- [ ] Add Save button that persists `node_addresses_text` to config -- [ ] Hide Fetch button and show manual-entry hint on Devnet/Regtest -- [ ] Show empty-state guidance when no addresses are configured -- [ ] Test light and dark themes -- [ ] Verify keyboard navigation (Tab through field, Fetch, Save) diff --git a/docs/ai-design/2026-03-30-dapi-discovery-button/wireframe.html b/docs/ai-design/2026-03-30-dapi-discovery-button/wireframe.html deleted file mode 100644 index e4395f443..000000000 --- a/docs/ai-design/2026-03-30-dapi-discovery-button/wireframe.html +++ /dev/null @@ -1,515 +0,0 @@ - - - - - -DAPI Discovery Button - Wireframe - - - -

DAPI Node Discovery Button

-

Network Settings Screen - Wireframe

- -
- - - -
-
Default (with addresses)
-
Empty State
-
Fetching
-
Success
-
Error
-
Devnet
-
Confirmation
-
-
- - - - - - - - -
-
Connection Settings
- - -
-
Network:
-
- Mainnet -
-
- - -
- - - - - - - - - - - - - - - -
-
- - - - - - - - (i) -
- Fetches the current list of available nodes from a service operated by Dash Core Group (DCG). This is a convenience service over HTTPS -- it does not access the blockchain directly. Platform proofs are verified independently, so incorrect node addresses cannot forge data, but they could prevent the app from connecting. -
-
-
- - -
- - -
- -
- - -
-
- - -
-
Connection Status
-
-
Core RPC: Connected
-
ZMQ: Connected
-
DAPI: 147/147 available
-
-
- - -
-
-
Update Node Addresses?
-
- This will replace your current node addresses with a fresh list fetched from the Dash network service. -

- You currently have 8 addresses configured. You can review and edit the new list before saving. -
-
- - -
-
-
- - - -