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 dedc9ef40..3b6a448b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,13 +109,13 @@ 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()`. - **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/src/app.rs b/src/app.rs index 22e8c4a27..6dfb2955d 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::{ @@ -65,31 +65,86 @@ 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, + /// 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 - 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 +296,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 +311,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 +368,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, chosen_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 +415,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 +558,15 @@ 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, + network_switch_banner: 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 +583,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 +613,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 +649,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 +693,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 +738,41 @@ 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, + // 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; } + // 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); + 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 + .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 +802,19 @@ 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. + app_context + .update_settings(RootScreenType::RootScreenNetworkChooser) + .ok(); } /// Update the connection status banner when the overall connection state @@ -1104,41 +909,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 +978,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 +1021,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 +1062,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 +1097,16 @@ 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.network_switch_banner.take_and_clear(); + 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 +1121,11 @@ impl App for AppState { .display_message(&msg, MessageType::Success); self.visible_screen_mut().refresh(); } + 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) => { // Let the screen handle specific error types first. // If handled, skip the generic error banner. @@ -1358,15 +1134,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); } @@ -1387,33 +1157,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 +1318,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 +1344,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..7f448765a 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,67 @@ pub enum TaskError { )] TokenPositionNotFound { position: u16 }, + /// The token name contains whitespace or control characters. + #[error( + "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, + 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 +972,18 @@ 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, 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, @@ -1151,6 +1225,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 +1290,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 +1378,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 +1522,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 +2448,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("badname"), + "Expected escaped 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..7a88a3e41 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. + /// 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, + }, + /// 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,10 @@ 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, + /// Network the balances were fetched from + network: Network, }, /// Platform credits transferred between addresses PlatformCreditsTransferred { @@ -333,6 +349,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 +451,60 @@ 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. + // 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, + detail: "AppContext::new() returned None".into(), + })?; + + 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..86a4ac217 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,17 @@ impl AppContext { wallet .platform_address_info .iter() - .map(|(addr, info)| (addr.clone(), (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() }; @@ -183,6 +194,7 @@ impl AppContext { Ok(BackendTaskSuccessResult::PlatformAddressBalances { seed_hash, balances, + network: self.network(), }) } } 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..1aeee5529 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; @@ -438,18 +439,20 @@ impl AppContext { } pub fn set_core_backend_mode(self: &Arc, mode: CoreBackendMode) { - self.core_backend_mode - .store(mode.as_u8(), Ordering::Relaxed); + self.set_core_backend_mode_inner(mode, true); + } - // 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 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) { // 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 => { @@ -475,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) @@ -495,9 +508,19 @@ impl AppContext { } /// Update the cached platform protocol version from epoch info. - pub fn set_platform_protocol_version(&self, version: u32) { + /// + /// 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 was_shielded = FeatureGate::Shielded.is_available(self); + self.platform_protocol_version - .store(version, Ordering::Relaxed); + .swap(version, Ordering::Relaxed); + + if !was_shielded && FeatureGate::Shielded.is_available(self) { + self.init_missing_shielded_wallets(); + } } /// Get a fee estimator configured with the cached fee multiplier. @@ -552,7 +575,7 @@ 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 credentials (cookie auth first, then user/pass). 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)?; diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index e8c739915..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 - // (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) { + // (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 FeatureGate::Shielded.is_available(self) { match self.initialize_shielded_wallet(seed_hash) { Ok(_) => { tracing::trace!( @@ -195,6 +196,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 +764,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..2159fcfe6 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()), tool_router: Self::tool_router(), } } @@ -48,32 +78,48 @@ 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.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>(()) + }) + .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 +255,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..371aad0b0 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,191 @@ 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 { + let spv_running = ctx.connection_status().spv_status().is_active(); + return Ok(NetworkSwitchOutput { + active: network_display_name(target).to_owned(), + spv_started: spv_running, + }); + } + + // 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..bcc2b8ce6 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -771,117 +771,180 @@ 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. + /// + /// 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 { + (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(_) => {},)* + } + } + } + + // 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(); + screen.reset_transient_state(); + 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(); + // 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::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. + // The `skip` list must exactly match the explicit match arms above. + set_ctx!( + set: + IdentitiesScreen, + DPNSScreen, + AddExistingIdentityScreen, + KeyInfoScreen, + KeysScreen, + WithdrawalScreen, + TransitionVisualizerScreen, + ContractVisualizerScreen, + AddKeyScreen, + DocumentQueryScreen, + AddNewIdentityScreen, + RegisterDpnsNameScreen, + RegisterDataContractScreen, + UpdateDataContractScreen, + DocumentActionScreen, + GroupActionsScreen, + TopUpIdentityScreen, + ProofLogScreen, + AddContractsScreen, + ProofVisualizerScreen, + DocumentVisualizerScreen, + PlatformInfoScreen, + GroveSTARKScreen, + TokensScreen, + TransferTokensScreen, + MintTokensScreen, + BurnTokensScreen, + DestroyFrozenFundsScreen, + FreezeTokensScreen, + UnfreezeTokensScreen, + PauseTokensScreen, + ResumeTokensScreen, + ClaimTokensScreen, + ViewTokenClaimsScreen, + UpdateTokenConfigScreen, + AddTokenById, + PurchaseTokenScreen, + SetTokenPriceScreen, + AssetLockDetailScreen, + DashPayAddContactScreen, + DashPayContactDetailsScreen, + DashPayContactProfileViewerScreen, + DashPaySendPaymentScreen, + DashPayContactInfoEditorScreen, + DashPayQRGeneratorScreen, + DashPayProfileSearchScreen; + skip: + NetworkChooserScreen, + AddNewWalletScreen, + TransferScreen, + WalletsBalancesScreen, + ImportMnemonicScreen, + WalletSendScreen, + SingleKeyWalletSendScreen, + CreateAssetLockScreen, + MasternodeListDiffScreen, + AddressBalanceScreen, + DashPayScreen, + ShieldScreen, + ShieldedSendScreen, + UnshieldCreditsScreen, + ); } } diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index a8a6cc7dc..946904f7f 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,46 @@ 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, + 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 { 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 +159,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 +206,24 @@ 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, + fetch_confirmation_dialog: None, + pending_reinit_after_discovery: 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 +377,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 +412,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 +479,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 +491,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 +499,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,17 +792,15 @@ 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")), + 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; @@ -857,19 +815,8 @@ 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.fetch_confirmation_dialog.is_some() { + app_action |= self.show_fetch_confirmation(ui); } }); @@ -1164,23 +1111,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 +1126,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 { + 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::Devnet, CoreBackendMode::Rpc); - } - if let Some(ref ctx) = self.local_app_context { - 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 +1225,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 +1282,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 +1335,6 @@ impl NetworkChooserScreen { { // Save to database let _ = self - .mainnet_app_context .db .update_auto_start_spv(self.auto_start_spv); } @@ -1816,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); @@ -2058,13 +1979,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 +2075,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()); } } @@ -2199,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 { @@ -2224,15 +2132,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 +2165,43 @@ 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 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 !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 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; + } + self.pending_reinit_after_discovery = true; } - } - 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/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 60f36c81c..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; @@ -1172,8 +1186,7 @@ impl WalletsBalancesScreen { tabs.insert(0, AccountTab::Category(AccountCategory::Bip44, Some(0))); } - // Add the Shielded tab only when the connected network supports it - // (all shielded state transitions present in the platform version). + // Add the Shielded tab only when the connected network supports it. if FeatureGate::Shielded.is_available(&self.app_context) { tabs.push(AccountTab::Shielded); } @@ -2875,16 +2888,27 @@ 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 { - // 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 + 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);