From 5a92f1e5af186c338f01742bd8f5112032c45466 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:48:39 +0100 Subject: [PATCH 01/48] feat: dynamic DAPI node discovery and config migration - Dynamic DAPI node discovery via TrustedHttpContextProvider when no addresses configured in .env (mainnet/testnet only) - Config migration: auto-comments old hardcoded DAPI addresses in .env - dapi_addresses field now Optional in NetworkConfig - Remove hardcoded fallback addresses (migration + discovery only) - Add error logging to env migration - Guard block_in_place with try_current() - Reduce discovery timeout to 10s - Fix dapi_address_list() return type to Result> Co-Authored-By: Claude Opus 4.6 --- .env.example | 8 +- Cargo.lock | 144 +++++++++++++++------------------- Cargo.toml | 1 + src/app.rs | 6 ++ src/config.rs | 178 ++++++++++++++++++++++++++++++++++-------- src/context/mod.rs | 33 ++++++-- src/dapi_discovery.rs | 80 +++++++++++++++++++ src/lib.rs | 1 + src/sdk_wrapper.rs | 8 +- src/spv/tests.rs | 2 +- 10 files changed, 330 insertions(+), 131 deletions(-) create mode 100644 src/dapi_discovery.rs diff --git a/.env.example b/.env.example index 224be27e4..ffd42fea0 100644 --- a/.env.example +++ b/.env.example @@ -2,14 +2,18 @@ # linux: ~/.config/dash-evo-tool/.env # Mac: ~/Library/ApplicationSupport/Dash-Evo-Tool/.env -MAINNET_dapi_addresses=https://104.200.24.196:443,https://134.255.182.185:443,https://134.255.182.186:443,https://134.255.182.187:443,https://134.255.183.247:443,https://134.255.183.248:443,https://134.255.183.250:443,https://135.181.110.216:443,https://146.59.4.9:443,https://147.135.199.138:443,https://149.28.241.190:443,https://149.28.247.165:443,https://157.10.199.125:443,https://157.10.199.77:443,https://157.10.199.79:443,https://157.10.199.82:443,https://157.66.81.130:443,https://157.66.81.162:443,https://157.66.81.218:443,https://157.90.238.161:443,https://159.69.204.162:443,https://167.179.90.255:443,https://167.88.169.16:443,https://168.119.102.10:443,https://172.104.90.249:443,https://173.212.239.124:443,https://173.249.53.139:443,https://178.157.91.184:443,https://185.158.107.124:443,https://185.192.96.70:443,https://185.194.216.84:443,https://185.197.250.227:443,https://185.198.234.17:443,https://185.215.166.126:443,https://188.208.196.183:443,https://188.245.90.255:443,https://192.248.178.237:443,https://193.203.15.209:443,https://194.146.13.7:443,https://194.195.87.34:443,https://198.7.115.43:443,https://207.244.247.40:443,https://213.199.34.248:443,https://213.199.34.250:443,https://213.199.34.251:443,https://213.199.35.15:443,https://213.199.35.18:443,https://213.199.35.6:443,https://213.199.44.112:443,https://2.58.82.231:443,https://31.220.84.93:443,https://31.220.85.180:443,https://31.220.88.116:443,https://37.27.83.17:443,https://37.60.236.151:443,https://37.60.236.161:443,https://37.60.236.201:443,https://37.60.236.212:443,https://37.60.236.247:443,https://37.60.236.249:443,https://37.60.243.119:443,https://37.60.243.59:443,https://37.60.244.220:443,https://44.240.99.214:443,https://49.12.102.105:443,https://49.13.154.121:443,https://49.13.193.251:443,https://49.13.237.193:443,https://49.13.28.255:443,https://51.195.118.43:443,https://51.83.191.208:443,https://5.189.186.78:443,https://52.10.213.198:443,https://52.33.9.172:443,https://54.69.95.118:443,https://5.75.133.148:443,https://64.23.134.67:443,https://65.108.246.145:443,https://65.109.65.126:443,https://65.21.145.147:443,https://79.137.71.84:443,https://81.17.101.141:443,https://91.107.204.136:443,https://91.107.226.241:443,https://93.190.140.101:443,https://93.190.140.111:443,https://93.190.140.112:443,https://93.190.140.114:443,https://93.190.140.162:443,https://95.216.146.18:443 +# DAPI addresses are resolved automatically for mainnet. +# Uncomment and set to override with specific nodes: +# MAINNET_dapi_addresses=https://node1:443,https://node2:443 MAINNET_core_host=127.0.0.1 MAINNET_core_rpc_port=9998 MAINNET_core_rpc_user=dashrpc MAINNET_core_rpc_password=password MAINNET_core_zmq_endpoint=tcp://127.0.0.1:23708 -TESTNET_dapi_addresses=https://34.214.48.68:1443,https://52.12.176.90:1443,https://52.34.144.50:1443,https://44.240.98.102:1443,https://54.201.32.131:1443,https://52.10.229.11:1443,https://52.13.132.146:1443,https://52.40.219.41:1443,https://54.149.33.167:1443,https://35.164.23.245:1443,https://52.33.28.47:1443,https://52.43.13.92:1443,https://52.89.154.48:1443,https://52.24.124.162:1443,https://35.85.21.179:1443,https://54.187.14.232:1443,https://54.68.235.201:1443,https://52.13.250.182:1443 +# DAPI addresses are resolved automatically for testnet. +# Uncomment and set to override with specific nodes: +# TESTNET_dapi_addresses=https://node1:1443,https://node2:1443 TESTNET_core_host=127.0.0.1 TESTNET_core_rpc_port=19998 TESTNET_core_rpc_user=dashrpc diff --git a/Cargo.lock b/Cargo.lock index e1bb06250..c45c5842a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -194,23 +194,21 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android-activity" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef6978589202a00cd7e118380c448a08b6ed394c3a8df3a430d0898e3a42d046" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" dependencies = [ "android-properties", "bitflags 2.11.0", "cc", - "cesu8", - "jni 0.21.1", - "jni-sys 0.3.1", + "jni 0.22.4", "libc", "log", "ndk", "ndk-context", "ndk-sys", "num_enum 0.7.6", - "thiserror 1.0.69", + "thiserror 2.0.18", ] [[package]] @@ -228,21 +226,6 @@ dependencies = [ "libc", ] -[[package]] -name = "anstream" -version = "0.6.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" -dependencies = [ - "anstyle", - "anstyle-parse 0.2.7", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - [[package]] name = "anstream" version = "1.0.0" @@ -250,7 +233,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", - "anstyle-parse 1.0.0", + "anstyle-parse", "anstyle-query", "anstyle-wincon", "colorchoice", @@ -264,15 +247,6 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" -[[package]] -name = "anstyle-parse" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" -dependencies = [ - "utf8parse", -] - [[package]] name = "anstyle-parse" version = "1.0.0" @@ -1461,7 +1435,7 @@ version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ - "anstream 1.0.0", + "anstream", "anstyle", "clap_lex", "strsim", @@ -1845,7 +1819,7 @@ dependencies = [ "objc2 0.5.2", "objc2-foundation 0.2.2", "web-sys", - "winreg 0.52.0", + "winreg", ] [[package]] @@ -1979,6 +1953,7 @@ dependencies = [ "resvg", "rfd", "rmcp", + "rs-sdk-trusted-context-provider", "rusqlite", "rust-embed", "serde", @@ -2970,9 +2945,9 @@ dependencies = [ [[package]] name = "env_filter" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" dependencies = [ "log", "regex", @@ -2986,11 +2961,11 @@ checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" [[package]] name = "env_logger" -version = "0.11.9" +version = "0.11.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" dependencies = [ - "anstream 0.6.21", + "anstream", "anstyle", "env_filter", "jiff", @@ -4280,6 +4255,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] @@ -4328,7 +4304,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2", "system-configuration", "tokio", "tower-service", @@ -4556,14 +4532,15 @@ dependencies = [ [[package]] name = "ipconfig" -version = "0.3.2" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" dependencies = [ - "socket2 0.5.10", + "socket2", "widestring", - "windows-sys 0.48.0", - "winreg 0.50.0", + "windows-registry", + "windows-result 0.4.1", + "windows-sys 0.61.2", ] [[package]] @@ -4901,9 +4878,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ "bitflags 2.11.0", "libc", @@ -5354,9 +5331,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-derive" @@ -6275,7 +6252,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.5+spec-1.1.0", + "toml_edit 0.25.8+spec-1.1.0", ] [[package]] @@ -6419,7 +6396,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls", - "socket2 0.6.3", + "socket2", "thiserror 2.0.18", "tokio", "tracing", @@ -6457,7 +6434,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2", "tracing", "windows-sys 0.60.2", ] @@ -6746,6 +6723,8 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", "rustls-pki-types", "serde", "serde_json", @@ -6753,6 +6732,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-rustls", "tower", "tower-http", "tower-service", @@ -6760,6 +6740,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots", ] [[package]] @@ -6977,6 +6958,25 @@ dependencies = [ "wasm-bindgen-futures", ] +[[package]] +name = "rs-sdk-trusted-context-provider" +version = "3.1.0-dev.1" +source = "git+https://github.com/dashpay/platform?rev=93556aa4b#93556aa4b97fa024890926e315c1061181c8b663" +dependencies = [ + "arc-swap", + "dash-context-provider", + "dpp", + "futures", + "hex", + "lru", + "reqwest 0.12.28", + "serde", + "serde_json", + "thiserror 2.0.18", + "tracing", + "url", +] + [[package]] name = "rs-x11-hash" version = "0.1.8" @@ -7687,16 +7687,6 @@ dependencies = [ "serde", ] -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "socket2" version = "0.6.3" @@ -8191,7 +8181,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.3", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] @@ -8307,9 +8297,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.0.1+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" dependencies = [ "serde_core", ] @@ -8340,21 +8330,21 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.5+spec-1.1.0" +version = "0.25.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1" +checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" dependencies = [ "indexmap 2.13.0", - "toml_datetime 1.0.1+spec-1.1.0", + "toml_datetime 1.1.0+spec-1.1.0", "toml_parser", "winnow 1.0.0", ] [[package]] name = "toml_parser" -version = "1.0.10+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" dependencies = [ "winnow 1.0.0", ] @@ -8378,7 +8368,7 @@ dependencies = [ "percent-encoding", "pin-project", "rustls-native-certs", - "socket2 0.6.3", + "socket2", "sync_wrapper", "tokio", "tokio-rustls", @@ -8698,9 +8688,9 @@ checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "da36089a805484bcccfffe0739803392c8298778a2d2f09febf76fac5ad9025b" [[package]] name = "unicode-vo" @@ -10132,16 +10122,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winreg" -version = "0.50.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" -dependencies = [ - "cfg-if", - "windows-sys 0.48.0", -] - [[package]] name = "winreg" version = "0.52.0" diff --git a/Cargo.toml b/Cargo.toml index b4987c475..25ef788c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ dash-sdk = { git = "https://github.com/dashpay/platform", rev = "93556aa4b", fea "core_rpc_client", "core_spv", ] } +rs-sdk-trusted-context-provider = { git = "https://github.com/dashpay/platform", rev = "93556aa4b" } grovestark = { git = "https://www.github.com/dashpay/grovestark", rev = "5b9e289cca54c79b1305d5f4f40bf1148f1eb0e3" } rayon = "1.8" thiserror = "2.0.18" diff --git a/src/app.rs b/src/app.rs index 38440dd13..aaac115b1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -196,6 +196,7 @@ impl AppState { let data_dir = app_user_data_dir_path()?; ensure_data_dir_exists(&data_dir)?; ensure_env_file(&data_dir); + crate::config::Config::migrate_env_file_if_needed(); initialize_logger(); let db_file_path = data_file_path(&data_dir, "data.db")?; let db = Arc::new(Database::new(&db_file_path)?); @@ -212,6 +213,7 @@ impl AppState { let data_dir = app_user_data_dir_path()?; ensure_data_dir_exists(&data_dir)?; ensure_env_file(&data_dir); + crate::config::Config::migrate_env_file_if_needed(); let db = Arc::new( crate::database::test_helpers::create_test_database() .map_err(|e| format!("Failed to create test database: {}", e))?, @@ -232,6 +234,9 @@ impl AppState { let subtasks = Arc::new(TaskManager::new()); let connection_status = Arc::new(ConnectionStatus::new()); + // TODO: Defer initialization of inactive network contexts (testnet, devnet, local) + // until the user actually switches to them. Eagerly constructing all contexts at + // startup performs DAPI discovery and SDK init for networks the user may never use. let mainnet_app_context = AppContext::new( data_dir.clone(), Network::Mainnet, @@ -942,6 +947,7 @@ impl AppState { } self.chosen_network = network; + let app_context = self.current_app_context().clone(); // Update MCP server's context to follow network switch diff --git a/src/config.rs b/src/config.rs index 8109359e0..b5ccca384 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,6 +8,20 @@ use dash_sdk::dpp::dashcore::Network; use serde::Deserialize; use tempfile::NamedTempFile; +/// Known old hardcoded mainnet DAPI address lists that should be migrated +/// to dynamic discovery. These are exact-match values from previous .env.example +/// files (pre-update and post-update from mnowatch.org scrape). +const KNOWN_OLD_MAINNET_ADDRESSES: &[&str] = &[ + // Original .env.example mainnet addresses (pre-mnowatch update) + "https://104.200.24.196:443,https://134.255.182.185:443,https://134.255.182.186:443,https://134.255.182.187:443,https://134.255.183.247:443,https://134.255.183.248:443,https://134.255.183.250:443,https://135.181.110.216:443,https://146.59.4.9:443,https://147.135.199.138:443,https://149.28.241.190:443,https://149.28.247.165:443,https://157.10.199.125:443,https://157.10.199.77:443,https://157.10.199.79:443,https://157.10.199.82:443,https://157.66.81.130:443,https://157.66.81.162:443,https://157.66.81.218:443,https://157.90.238.161:443,https://159.69.204.162:443,https://167.179.90.255:443,https://167.88.169.16:443,https://168.119.102.10:443,https://172.104.90.249:443,https://173.212.239.124:443,https://173.249.53.139:443,https://178.157.91.184:443,https://185.158.107.124:443,https://185.192.96.70:443,https://185.194.216.84:443,https://185.197.250.227:443,https://185.198.234.17:443,https://185.215.166.126:443,https://188.208.196.183:443,https://188.245.90.255:443,https://192.248.178.237:443,https://193.203.15.209:443,https://194.146.13.7:443,https://194.195.87.34:443,https://198.7.115.43:443,https://207.244.247.40:443,https://213.199.34.248:443,https://213.199.34.250:443,https://213.199.34.251:443,https://213.199.35.15:443,https://213.199.35.18:443,https://213.199.35.6:443,https://213.199.44.112:443,https://2.58.82.231:443,https://31.220.84.93:443,https://31.220.85.180:443,https://31.220.88.116:443,https://37.27.83.17:443,https://37.60.236.151:443,https://37.60.236.161:443,https://37.60.236.201:443,https://37.60.236.212:443,https://37.60.236.247:443,https://37.60.236.249:443,https://37.60.243.119:443,https://37.60.243.59:443,https://37.60.244.220:443,https://44.240.99.214:443,https://49.12.102.105:443,https://49.13.154.121:443,https://49.13.193.251:443,https://49.13.237.193:443,https://49.13.28.255:443,https://51.195.118.43:443,https://51.83.191.208:443,https://5.189.186.78:443,https://52.10.213.198:443,https://52.33.9.172:443,https://54.69.95.118:443,https://5.75.133.148:443,https://64.23.134.67:443,https://65.108.246.145:443,https://65.109.65.126:443,https://65.21.145.147:443,https://79.137.71.84:443,https://81.17.101.141:443,https://91.107.204.136:443,https://91.107.226.241:443,https://93.190.140.101:443,https://93.190.140.111:443,https://93.190.140.112:443,https://93.190.140.114:443,https://93.190.140.162:443,https://95.216.146.18:443", + // mnowatch.org-scraped mainnet addresses (279 nodes, 2026-03-17) + "https://147.45.183.128:443,https://216.238.75.46:443,https://89.125.209.110:443,https://84.247.180.201:443,https://134.255.182.186:443,https://93.115.172.39:443,https://5.189.164.253:443,https://38.242.197.189:443,https://83.222.9.253:443,https://95.111.241.20:443,https://66.245.196.52:443,https://173.212.245.118:443,https://202.71.14.79:443,https://185.252.234.238:443,https://178.215.237.134:443,https://207.180.233.6:443,https://89.125.209.69:443,https://136.244.99.17:443,https://173.212.232.90:443,https://92.255.76.59:443,https://37.60.244.253:443,https://157.90.238.161:443,https://85.92.111.111:443,https://213.199.54.171:443,https://84.247.180.198:443,https://82.211.21.252:443,https://161.97.75.36:443,https://23.88.63.58:443,https://207.244.247.40:443,https://45.32.70.131:443,https://31.220.91.43:443,https://52.33.9.172:443,https://161.97.88.199:443,https://109.199.124.30:443,https://185.198.234.17:443,https://139.84.236.208:443,https://38.242.218.26:443,https://207.180.224.96:443,https://62.171.170.222:443,https://75.119.138.9:443,https://75.119.153.10:443,https://31.220.91.60:443,https://82.211.21.251:443,https://95.179.159.65:443,https://44.240.99.214:443,https://5.75.133.148:443,https://213.199.54.37:443,https://161.97.88.219:443,https://89.35.131.149:443,https://192.248.178.237:443,https://161.97.153.122:443,https://37.60.235.218:443,https://207.180.241.242:443,https://195.26.254.228:443,https://45.77.11.194:443,https://139.84.232.129:443,https://161.97.175.233:443,https://147.45.103.99:443,https://89.125.209.195:443,https://91.198.108.35:443,https://85.193.90.107:443,https://161.97.180.105:443,https://213.199.54.34:443,https://65.108.246.145:443,https://64.176.10.71:443,https://158.247.247.241:443,https://37.60.254.213:443,https://149.102.140.101:443,https://139.180.143.115:443,https://37.60.235.205:443,https://213.199.44.112:443,https://213.199.53.161:443,https://178.253.42.64:443,https://162.212.35.100:443,https://185.239.209.6:443,https://62.171.138.186:443,https://173.212.196.214:443,https://37.60.254.202:443,https://134.255.182.185:443,https://139.84.137.143:443,https://144.91.87.82:443,https://161.97.91.217:443,https://82.211.21.249:443,https://194.163.159.171:443,https://80.240.19.200:443,https://144.126.141.62:443,https://173.249.21.12:443,https://161.97.159.172:443,https://194.163.156.190:443,https://139.84.170.10:443,https://164.68.118.37:443,https://143.198.145.184:443,https://84.247.180.200:443,https://37.60.234.205:443,https://43.133.171.101:443,https://192.248.175.198:443,https://81.200.152.144:443,https://161.97.142.210:443,https://43.167.244.109:443,https://92.53.120.89:443,https://172.236.244.81:443,https://62.171.144.192:443,https://146.59.153.204:443,https://84.247.180.190:443,https://185.215.164.84:443,https://172.238.7.25:443,https://157.173.122.20:443,https://45.153.70.126:443,https://185.141.216.4:443,https://82.208.20.153:443,https://95.111.239.54:443,https://85.190.243.3:443,https://51.195.235.166:443,https://156.67.29.45:443,https://82.211.21.38:443,https://93.115.172.37:443,https://89.35.131.39:443,https://93.115.172.38:443,https://161.97.104.37:443,https://75.119.128.71:443,https://161.97.117.125:443,https://49.13.28.255:443,https://52.36.102.91:443,https://139.99.201.103:443,https://109.199.120.79:443,https://161.97.74.173:443,https://168.119.102.10:443,https://45.135.180.79:443,https://49.13.237.193:443,https://45.135.180.130:443,https://173.212.251.130:443,https://38.242.206.103:443,https://37.60.254.201:443,https://75.119.156.254:443,https://37.27.83.17:443,https://38.242.145.178:443,https://45.135.180.114:443,https://66.29.147.83:443,https://161.97.83.102:443,https://37.60.236.230:443,https://89.35.131.23:443,https://89.125.50.206:443,https://37.60.244.35:443,https://38.242.206.56:443,https://194.163.166.76:443,https://70.34.206.123:443,https://51.158.169.237:443,https://213.199.54.35:443,https://108.61.165.170:443,https://89.35.131.219:443,https://161.97.163.75:443,https://185.166.217.154:443,https://157.173.122.21:443,https://37.60.236.41:443,https://38.242.200.227:443,https://193.168.3.82:443,https://149.28.223.171:443,https://217.76.54.175:443,https://167.86.93.21:443,https://109.123.244.131:443,https://155.133.26.122:443,https://51.195.47.118:443,https://45.135.180.70:443,https://167.88.169.16:443,https://216.238.99.9:443,https://37.60.244.87:443,https://91.222.237.98:443,https://157.173.122.26:443,https://82.211.21.18:443,https://52.10.213.198:443,https://139.84.231.221:443,https://45.94.58.58:443,https://178.18.254.136:443,https://84.247.180.205:443,https://161.97.106.14:443,https://49.13.193.251:443,https://78.141.225.100:443,https://77.232.129.86:443,https://91.198.108.37:443,https://167.86.94.138:443,https://89.23.112.100:443,https://95.179.241.182:443,https://92.63.176.202:443,https://188.225.39.14:443,https://176.57.213.170:443,https://93.115.172.36:443,https://82.211.21.16:443,https://37.60.235.224:443,https://95.216.146.18:443,https://167.114.153.110:443,https://37.60.235.89:443,https://95.111.233.139:443,https://109.199.97.144:443,https://37.60.235.24:443,https://31.220.84.93:443,https://161.97.180.182:443,https://161.97.91.68:443,https://89.125.209.120:443,https://89.125.50.14:443,https://161.97.179.214:443,https://45.85.147.192:443,https://37.60.254.205:443,https://109.199.124.135:443,https://158.220.101.10:443,https://87.228.24.64:443,https://5.189.151.7:443,https://158.247.208.247:443,https://37.60.254.211:443,https://64.176.35.235:443,https://46.62.250.247:443,https://185.215.164.186:443,https://161.97.102.156:443,https://89.125.209.106:443,https://213.171.12.108:443,https://91.198.108.36:443,https://49.12.102.105:443,https://37.60.246.228:443,https://89.125.209.178:443,https://147.45.236.121:443,https://72.62.58.108:443,https://62.171.136.245:443,https://54.69.95.118:443,https://31.220.77.84:443,https://185.119.57.243:443,https://161.97.96.120:443,https://89.125.209.234:443,https://195.201.238.55:443,https://135.181.110.216:443,https://207.180.213.141:443,https://89.223.120.216:443,https://45.76.141.74:443,https://161.97.66.31:443,https://161.97.85.182:443,https://84.247.187.76:443,https://188.245.90.255:443,https://82.211.21.250:443,https://144.91.106.188:443,https://167.235.102.194:443,https://164.68.114.36:443,https://43.167.239.145:443,https://207.180.233.246:443,https://167.86.77.58:443,https://185.239.208.110:443,https://75.119.137.66:443,https://157.173.122.25:443,https://82.211.21.40:443,https://89.125.209.152:443,https://62.171.170.14:443,https://45.77.129.235:443,https://185.209.230.13:443,https://75.119.152.219:443,https://37.60.234.168:443,https://89.23.117.144:443,https://37.60.243.59:443,https://89.35.131.218:443,https://193.168.3.112:443,https://5.189.145.80:443,https://178.18.244.255:443,https://37.60.235.39:443,https://77.221.148.204:443,https://89.125.209.27:443,https://91.198.108.38:443,https://207.180.218.245:443,https://77.42.74.68:443,https://158.220.101.28:443,https://89.35.131.158:443,https://161.97.106.164:443,https://185.202.236.58:443,https://173.199.71.83:443,https://185.215.166.126:443,https://57.131.28.197:443,https://217.25.89.156:443,https://72.60.38.160:443,https://82.97.240.124:443,https://89.125.209.133:443,https://213.159.77.221:443,https://109.199.119.192:443,https://114.132.172.215:443,https://89.125.209.156:443", +]; + +const KNOWN_OLD_TESTNET_ADDRESSES: &[&str] = &[ + "https://34.214.48.68:1443,https://52.12.176.90:1443,https://52.34.144.50:1443,https://44.240.98.102:1443,https://54.201.32.131:1443,https://52.10.229.11:1443,https://52.13.132.146:1443,https://52.40.219.41:1443,https://54.149.33.167:1443,https://35.164.23.245:1443,https://52.33.28.47:1443,https://52.43.13.92:1443,https://52.89.154.48:1443,https://52.24.124.162:1443,https://35.85.21.179:1443,https://54.187.14.232:1443,https://54.68.235.201:1443,https://52.13.250.182:1443", +]; + #[derive(Debug, Deserialize, Clone)] pub struct Config { pub mainnet_config: Option, @@ -40,8 +54,9 @@ pub enum ConfigError { #[derive(Debug, Deserialize, Clone)] pub struct NetworkConfig { - /// Hostname of Dash Platform node to connect to - pub dapi_addresses: String, + /// Hostname of Dash Platform node to connect to. + /// `None` or empty means dynamic discovery will be used. + pub dapi_addresses: Option, /// Host of the Dash Core RPC interface pub core_host: String, /// Port of the Dash Core RPC interface @@ -103,12 +118,12 @@ impl Config { // matches what `load()` expects (i.e. `envy::prefixed("MAINNET_")`, // etc.). - writeln!( - env_file, - "{}dapi_addresses={}", - prefix, config.dapi_addresses - ) - .map_err(|e| ConfigError::SaveError { source: e })?; + if let Some(ref addrs) = config.dapi_addresses + && !addrs.is_empty() + { + writeln!(env_file, "{}dapi_addresses={}", prefix, addrs) + .map_err(|e| ConfigError::SaveError { source: e })?; + } writeln!(env_file, "{}core_host={}", prefix, config.core_host) .map_err(|e| ConfigError::SaveError { source: e })?; writeln!(env_file, "{}core_rpc_port={}", prefix, config.core_rpc_port) @@ -261,6 +276,92 @@ impl Config { }) } + /// Migrate old hardcoded DAPI addresses in the `.env` file to dynamic discovery. + /// + /// Scans for `MAINNET_dapi_addresses=` and `TESTNET_dapi_addresses=` lines whose + /// values match a known old hardcoded list. Matching lines are commented out so + /// that `envy` will no longer parse them and dynamic discovery kicks in. + pub fn migrate_env_file_if_needed() { + let env_file_path = match app_user_data_file_path(".env") { + Ok(p) => p, + Err(e) => { + tracing::warn!(error = %e, "Could not determine .env file path for migration"); + return; + } + }; + + let content = match std::fs::read_to_string(&env_file_path) { + Ok(c) => c, + Err(e) => { + tracing::warn!(error = %e, path = ?env_file_path, "Could not read .env file for migration"); + return; + } + }; + + let mut changed = false; + let mut new_lines: Vec = Vec::new(); + + for line in content.lines() { + if let Some(value) = line.strip_prefix("MAINNET_dapi_addresses=") + && KNOWN_OLD_MAINNET_ADDRESSES + .iter() + .any(|old| value.trim() == *old) + { + new_lines.push(format!("# {line} # Migrated to dynamic discovery")); + changed = true; + continue; + } + if let Some(value) = line.strip_prefix("TESTNET_dapi_addresses=") + && KNOWN_OLD_TESTNET_ADDRESSES + .iter() + .any(|old| value.trim() == *old) + { + new_lines.push(format!("# {line} # Migrated to dynamic discovery")); + changed = true; + continue; + } + new_lines.push(line.to_string()); + } + + if !changed { + return; + } + + let parent_dir = match env_file_path.parent() { + Some(p) => p, + None => { + tracing::warn!(path = ?env_file_path, "No parent directory for .env file during migration"); + return; + } + }; + + let mut new_content = new_lines.join("\n"); + if content.ends_with('\n') { + new_content.push('\n'); + } + + let mut tmp = match NamedTempFile::new_in(parent_dir) { + Ok(t) => t, + Err(e) => { + tracing::warn!(error = %e, "Failed to create temp file for .env migration"); + return; + } + }; + if let Err(e) = tmp.write_all(new_content.as_bytes()) { + tracing::warn!(error = %e, "Failed to write migrated .env file"); + return; + } + if let Err(e) = tmp.as_file().sync_all() { + tracing::warn!(error = %e, "Failed to sync migrated .env file"); + return; + } + if let Err(e) = tmp.persist(&env_file_path) { + tracing::warn!(error = %e, "Failed to persist migrated .env file"); + return; + } + tracing::info!("Migrated old hardcoded DAPI addresses to dynamic discovery"); + } + /// Update (overwrite) the configuration for a particular network. pub fn update_config_for_network(&mut self, network: Network, new_config: NetworkConfig) { match network { @@ -286,17 +387,21 @@ impl NetworkConfig { !self.core_rpc_user.is_empty() && !self.core_rpc_password.is_empty() && self.core_rpc_port != 0 - && !self.dapi_addresses.is_empty() } - /// List of DAPI addresses - pub fn dapi_address_list(&self) -> Result { - AddressList::from_str(&self.dapi_addresses).map_err(|e| { - format!( - "Could not parse DAPI addresses '{}': {}", - self.dapi_addresses, e - ) - }) + /// List of DAPI addresses, if explicitly configured. + /// 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(), + None => return Ok(None), + }; + if addrs.is_empty() { + return Ok(None); + } + AddressList::from_str(addrs) + .map(Some) + .map_err(|e| format!("Could not parse DAPI addresses '{addrs}': {e}")) } /// Update just the `core_rpc_password` in a builder-like manner. @@ -313,8 +418,13 @@ mod tests { /// Helper to create a minimal valid NetworkConfig for testing fn make_network_config(dapi_addresses: &str, port: u16) -> NetworkConfig { + let dapi = if dapi_addresses.is_empty() { + None + } else { + Some(dapi_addresses.to_string()) + }; NetworkConfig { - dapi_addresses: dapi_addresses.to_string(), + dapi_addresses: dapi, core_host: "127.0.0.1".to_string(), core_rpc_port: port, core_rpc_user: "dashrpc".to_string(), @@ -354,9 +464,9 @@ mod tests { } #[test] - fn test_is_valid_empty_dapi_addresses() { + fn test_is_valid_no_dapi_addresses() { let config = make_network_config("", 9998); - assert!(!config.is_valid()); + assert!(config.is_valid()); } // ── NetworkConfig::dapi_address_list ───────────────────────────── @@ -364,7 +474,7 @@ mod tests { #[test] fn test_dapi_address_list_single_address() { let config = make_network_config("https://127.0.0.1:443", 9998); - let list = config.dapi_address_list().unwrap(); + let list = config.dapi_address_list().unwrap().unwrap(); assert_eq!(list.len(), 1); } @@ -374,20 +484,14 @@ mod tests { "https://127.0.0.1:443,https://192.168.1.1:443,https://10.0.0.1:443", 9998, ); - let list = config.dapi_address_list().unwrap(); + let list = config.dapi_address_list().unwrap().unwrap(); assert_eq!(list.len(), 3); } #[test] - fn test_dapi_address_list_empty_returns_error() { + fn test_dapi_address_list_empty_returns_none() { let config = make_network_config("", 9998); - let result = config.dapi_address_list(); - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .contains("Could not parse DAPI addresses") - ); + assert!(config.dapi_address_list().unwrap().is_none()); } // ── NetworkConfig::update_core_rpc_password ───────────────────── @@ -479,7 +583,10 @@ mod tests { config.update_config_for_network(Network::Mainnet, new_cfg); let main = config.mainnet_config.as_ref().unwrap(); assert_eq!(main.core_rpc_port, 2222); - assert_eq!(main.dapi_addresses, "https://new.example.com:443"); + assert_eq!( + main.dapi_addresses.as_deref(), + Some("https://new.example.com:443") + ); } #[test] @@ -545,7 +652,9 @@ mod tests { // Simulate what save() writes by formatting env lines let mut output = String::new(); if let Some(ref cfg) = config.mainnet_config { - output.push_str(&format!("MAINNET_dapi_addresses={}\n", cfg.dapi_addresses)); + if let Some(ref addrs) = cfg.dapi_addresses { + output.push_str(&format!("MAINNET_dapi_addresses={}\n", addrs)); + } output.push_str(&format!("MAINNET_core_host={}\n", cfg.core_host)); output.push_str(&format!("MAINNET_core_rpc_port={}\n", cfg.core_rpc_port)); output.push_str(&format!("MAINNET_core_rpc_user={}\n", cfg.core_rpc_user)); @@ -592,7 +701,10 @@ mod tests { .from_iter(env_map.iter().map(|(k, v)| (k.clone(), v.clone()))); assert!(result.is_ok(), "Failed to parse: {:?}", result.err()); let config = result.unwrap(); - assert_eq!(config.dapi_addresses, "https://1.2.3.4:443"); + assert_eq!( + config.dapi_addresses.as_deref(), + Some("https://1.2.3.4:443") + ); assert_eq!(config.core_host, "192.168.1.100"); assert_eq!(config.core_rpc_port, 9998); assert_eq!(config.core_rpc_user, "testuser"); diff --git a/src/context/mod.rs b/src/context/mod.rs index 83647d30a..6c5986ae4 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -147,8 +147,21 @@ impl AppContext { } }; + // Resolve DAPI addresses: try explicit config first (sync), fall back to discovery (async) + let address_list = match crate::dapi_discovery::resolve_dapi_addresses_sync( + network, + &network_config.dapi_addresses, + network_config.devnet_name.as_deref(), + ) { + Ok(list) => list, + Err(e) => { + tracing::error!(?network, error = %e, "Failed to resolve DAPI addresses"); + return None; + } + }; + // Default to SPV provider initially; UI can switch backend after - let sdk = match initialize_sdk(&network_config, network, spv_provider.clone()) { + let sdk = match initialize_sdk(address_list, network, spv_provider.clone()) { Ok(sdk) => sdk, Err(e) => { tracing::error!("Failed to initialize SDK: {e}"); @@ -511,29 +524,33 @@ impl AppContext { detail: e.to_string(), })?; - // 3. Rebuild the Sdk with the updated config and current backend mode + // 3. Resolve DAPI addresses and rebuild the SDK + let address_list = crate::dapi_discovery::resolve_dapi_addresses_sync( + self.network, + &cfg.dapi_addresses, + cfg.devnet_name.as_deref(), + ) + .map_err(|e| TaskError::SdkInitializationFailed { detail: e })?; + let new_sdk = match self.core_backend_mode() { CoreBackendMode::Spv => { - // Reuse existing SPV provider (rebinding below to ensure context is set) let provider = self.spv_context_provider.read()?.clone(); - initialize_sdk(&cfg, self.network, provider) + initialize_sdk(address_list, self.network, provider) .map_err(|e| TaskError::SdkInitializationFailed { detail: e })? } CoreBackendMode::Rpc => { - // Create a fresh RPC provider with the new config let rpc_provider = RpcProvider::new(self.db.clone(), self.network, &cfg) .map_err(|e| TaskError::RpcProviderCreationFailed { detail: e })?; - // Swap in the updated RPC provider for future switches { let mut guard = self.rpc_context_provider.write()?; *guard = rpc_provider.clone(); } - initialize_sdk(&cfg, self.network, rpc_provider) + initialize_sdk(address_list, self.network, rpc_provider) .map_err(|e| TaskError::SdkInitializationFailed { detail: e })? } }; - // 4. Swap them in + // 4. Swap in the new SDK and client { let mut client_lock = self.core_client.write()?; *client_lock = new_client; diff --git a/src/dapi_discovery.rs b/src/dapi_discovery.rs new file mode 100644 index 000000000..26166674e --- /dev/null +++ b/src/dapi_discovery.rs @@ -0,0 +1,80 @@ +use dash_sdk::dapi_client::{Address as DapiAddress, AddressList}; +use dash_sdk::dpp::dashcore::Network; +use rs_sdk_trusted_context_provider::TrustedHttpContextProvider; +use std::num::NonZeroUsize; +use std::str::FromStr; +use std::time::Duration; + +/// Resolve DAPI addresses synchronously. +/// +/// If explicit addresses are configured, parses them directly. For mainnet and +/// testnet without explicit config, attempts dynamic discovery via +/// `tokio::task::block_in_place` (requires a tokio runtime). Returns an error +/// if discovery fails or no runtime is available. +pub fn resolve_dapi_addresses_sync( + network: Network, + dapi_addresses: &Option, + devnet_name: Option<&str>, +) -> Result { + if let Some(addrs) = dapi_addresses { + let trimmed = addrs.trim(); + if !trimmed.is_empty() { + return AddressList::from_str(trimmed) + .map_err(|e| format!("Invalid DAPI addresses: {e}")); + } + } + + match network { + Network::Mainnet | Network::Testnet => {} + _ => return Err("DAPI addresses must be configured in .env for this network".into()), + } + + match tokio::runtime::Handle::try_current() { + Ok(handle) => tokio::task::block_in_place(|| { + handle.block_on(try_discover_nodes(network, devnet_name)) + }), + Err(_) => Err("No tokio runtime available for dynamic DAPI discovery".to_string()), + } +} + +async fn try_discover_nodes( + network: Network, + devnet_name: Option<&str>, +) -> Result { + let provider = TrustedHttpContextProvider::new( + network, + devnet_name.map(|s| s.to_string()), + NonZeroUsize::new(10).unwrap(), + ) + .map_err(|e| format!("Could not initialize node discovery: {e}"))?; + + let urls = tokio::time::timeout( + Duration::from_secs(10), + provider.fetch_masternode_addresses(), + ) + .await + .map_err(|_| "Node discovery timed out after 10 seconds".to_string())? + .map_err(|e| format!("Node discovery request failed: {e}"))?; + + let mut address_list = AddressList::new(); + for url in urls { + match DapiAddress::from_str(url.as_str()) { + Ok(addr) => { + address_list.add(addr); + } + Err(e) => tracing::debug!(url = %url, error = %e, "Skipping invalid address"), + } + } + + if address_list.is_empty() { + return Err("Dynamic discovery returned no valid DAPI addresses".to_string()); + } + + tracing::info!( + ?network, + count = address_list.len(), + "Discovered DAPI nodes dynamically" + ); + + Ok(address_list) +} diff --git a/src/lib.rs b/src/lib.rs index efed22796..8ef521340 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ pub mod context; pub mod context_provider; pub mod context_provider_spv; pub mod cpu_compatibility; +pub mod dapi_discovery; pub mod database; pub mod logging; #[cfg(any(feature = "mcp", feature = "cli"))] diff --git a/src/sdk_wrapper.rs b/src/sdk_wrapper.rs index ae3df534f..0d0dbd3f7 100644 --- a/src/sdk_wrapper.rs +++ b/src/sdk_wrapper.rs @@ -1,18 +1,16 @@ -use crate::config::NetworkConfig; use crate::context::default_platform_version; +use dash_sdk::dapi_client::AddressList; use dash_sdk::dpp::dashcore::Network; use dash_sdk::platform::ContextProvider; -use dash_sdk::{RequestSettings, Sdk, SdkBuilder}; // Adjust imports +use dash_sdk::{RequestSettings, Sdk, SdkBuilder}; use std::time::Duration; use tracing::info; pub fn initialize_sdk( - config: &NetworkConfig, + address_list: AddressList, network: Network, context_provider: P, ) -> Result { - // Setup Platform SDK - let address_list = config.dapi_address_list()?; let request_settings = RequestSettings { connect_timeout: Some(Duration::from_secs(1)), timeout: Some(Duration::from_secs(10)), diff --git a/src/spv/tests.rs b/src/spv/tests.rs index 45c38ea6b..3f43f16ad 100644 --- a/src/spv/tests.rs +++ b/src/spv/tests.rs @@ -15,7 +15,7 @@ const DEADLOCK_TIMEOUT: Duration = Duration::from_secs(10); /// Create a minimal testnet NetworkConfig for testing. fn test_network_config() -> NetworkConfig { NetworkConfig { - dapi_addresses: "https://127.0.0.1:1443".to_string(), + dapi_addresses: Some("https://127.0.0.1:1443".to_string()), core_host: "127.0.0.1".to_string(), core_rpc_port: 19998, core_rpc_user: "dashrpc".to_string(), From 4b9dab021cefe4a724d996ee219d2349303fa360 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:59:53 +0200 Subject: [PATCH 02/48] chore: update Cargo.lock after merge Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 23 +++++------------------ 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1b8382322..0d24ce9c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4445,7 +4445,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.58.0", ] [[package]] @@ -6538,7 +6538,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ "heck", - "itertools 0.14.0", + "itertools 0.10.5", "log", "multimap", "petgraph", @@ -6559,7 +6559,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools 0.10.5", "proc-macro2", "quote", "syn 2.0.117", @@ -7230,7 +7230,7 @@ dependencies = [ [[package]] name = "rs-sdk-trusted-context-provider" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=93556aa4b#93556aa4b97fa024890926e315c1061181c8b663" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" dependencies = [ "arc-swap", "dash-context-provider", @@ -9835,7 +9835,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -9902,19 +9902,6 @@ dependencies = [ "windows-strings 0.4.2", ] -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - [[package]] name = "windows-future" version = "0.2.1" From ea1066e30f1c61f23c12c4c630e8c23cd696c2c1 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:20:33 +0200 Subject: [PATCH 03/48] fix(config): restore 0o600 permissions on .env after migration rename The migration used atomic rename but did not restore restrictive file permissions afterwards. The .env file contains RPC credentials and should be owner-read/write only on Unix. Fixes: SEC-001 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/config.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index 7e02d199d..d6ecb5f81 100644 --- a/src/config.rs +++ b/src/config.rs @@ -369,7 +369,18 @@ impl Config { tracing::warn!(error = %e, "Failed to persist migrated .env file"); return; } - tracing::info!("Migrated old hardcoded DAPI addresses to dynamic discovery"); + + // Restrict file permissions on Unix (config contains RPC credentials). + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o600); + if let Err(e) = std::fs::set_permissions(&env_file_path, perms) { + eprintln!("[migration] Could not set config file permissions to 0600: {e}"); + } + } + + eprintln!("[migration] Migrated old hardcoded DAPI addresses to dynamic discovery"); } /// Update (overwrite) the configuration for a particular network. From 1aa09d7746cfb61df5d35bacb954a44f08337962 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:21:06 +0200 Subject: [PATCH 04/48] docs(dapi_discovery): document trust model for DAPI node discovery Add module-level documentation explaining what endpoint is contacted, that TLS is used, and the trust assumptions (convenience layer, not security layer -- Platform proofs still verify independently). Fixes: SEC-003 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/dapi_discovery.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/dapi_discovery.rs b/src/dapi_discovery.rs index 26166674e..51a9186b0 100644 --- a/src/dapi_discovery.rs +++ b/src/dapi_discovery.rs @@ -1,3 +1,26 @@ +//! Dynamic DAPI node discovery via the Dash trusted masternode endpoint. +//! +//! When no explicit DAPI addresses are configured in `.env`, this module +//! contacts a Dash-operated HTTPS service to discover available masternodes: +//! +//! - **Mainnet**: `https://quorums.mainnet.networks.dash.org/masternodes` +//! - **Testnet**: `https://quorums.testnet.networks.dash.org/masternodes` +//! - **Devnet**: `https://quorums.devnet..networks.dash.org/masternodes` +//! +//! ## Trust model +//! +//! The discovery endpoint is operated by DCG (Dash Core Group) and served +//! over TLS. The application trusts this endpoint to return a correct list +//! of masternodes. An attacker who compromises the endpoint or performs a +//! TLS MITM could direct the client to malicious DAPI nodes, which would +//! then be subject to Platform's own proof-verification before any state +//! is accepted. Discovery is therefore a *convenience trust* layer, not a +//! *security trust* layer -- incorrect results degrade availability but +//! cannot forge Platform proofs. +//! +//! Results are not cached across restarts; discovery runs once per +//! `AppContext` initialization for the active network. + use dash_sdk::dapi_client::{Address as DapiAddress, AddressList}; use dash_sdk::dpp::dashcore::Network; use rs_sdk_trusted_context_provider::TrustedHttpContextProvider; From eb903895eee310cef5b0babd85ecdf789487ebff Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:29:55 +0200 Subject: [PATCH 05/48] perf(app): defer non-active network context creation until switch Only create AppContext for the saved/active network at startup; other networks are initialized lazily when the user switches to them via change_network(). This eliminates DAPI discovery + SDK init for networks the user may never use, reducing worst-case startup time from ~40s to ~10s. Fixes: CODE-001 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app.rs | 130 +++++++++++++++++++++++++++++++++++------------------ 1 file changed, 87 insertions(+), 43 deletions(-) diff --git a/src/app.rs b/src/app.rs index 08266c4b4..608416173 100644 --- a/src/app.rs +++ b/src/app.rs @@ -11,6 +11,7 @@ use crate::context::connection_status::{ConnectionStatus, OverallConnectionState use crate::database::Database; #[cfg(not(feature = "testing"))] use crate::logging::initialize_logger; +use crate::model::password_info::PasswordInfo; use crate::model::settings::Settings; use crate::spv::CoreBackendMode; use crate::ui::components::{BannerHandle, MessageBanner}; @@ -65,6 +66,17 @@ impl From> for TaskResult { } } +/// Parameters needed for lazy `AppContext` creation when the user switches +/// to a network whose context was deferred at startup. +struct LazyContextParams { + data_dir: PathBuf, + db: Arc, + password_info: Option, + subtasks: Arc, + connection_status: Arc, + egui_ctx: egui::Context, +} + pub struct AppState { pub main_screens: BTreeMap, pub selected_main_screen: RootScreenType, @@ -75,6 +87,8 @@ pub struct AppState { pub testnet_app_context: Option>, pub devnet_app_context: Option>, pub local_app_context: Option>, + /// Params kept for lazy AppContext creation when switching networks. + lazy_ctx_params: 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 @@ -240,46 +254,54 @@ impl AppState { let subtasks = Arc::new(TaskManager::new()); let connection_status = Arc::new(ConnectionStatus::new()); - // TODO: Defer initialization of inactive network contexts (testnet, devnet, local) - // until the user actually switches to them. Eagerly constructing all contexts at - // startup performs DAPI discovery and SDK init for networks the user may never use. - let mainnet_app_context = AppContext::new( - data_dir.clone(), - Network::Mainnet, - db.clone(), - password_info.clone(), - subtasks.clone(), - connection_status.clone(), - ctx.clone(), - ) - .ok_or("Failed to create AppContext for mainnet. Check your Dash configuration.")?; - let testnet_app_context = AppContext::new( - data_dir.clone(), - Network::Testnet, - db.clone(), - password_info.clone(), - subtasks.clone(), - connection_status.clone(), - ctx.clone(), - ); - let devnet_app_context = AppContext::new( - data_dir.clone(), - Network::Devnet, - db.clone(), - password_info.clone(), - subtasks.clone(), - connection_status.clone(), - ctx.clone(), - ); - let local_app_context = AppContext::new( + + let saved_network = settings.network; + + // Build a helper to create AppContext for a given network. + let make_context = |network: Network| -> Option> { + AppContext::new( + data_dir.clone(), + network, + db.clone(), + password_info.clone(), + subtasks.clone(), + connection_status.clone(), + ctx.clone(), + ) + }; + + // Always create mainnet eagerly (required, non-optional). + let mainnet_app_context = make_context(Network::Mainnet) + .ok_or("Failed to create AppContext for mainnet. Check your Dash configuration.")?; + + // Only create the saved/active network eagerly; defer others until the + // user switches to them. This avoids DAPI discovery + SDK init for + // networks the user may never use, eliminating potential startup delays. + let testnet_app_context = if saved_network == Network::Testnet { + make_context(Network::Testnet) + } else { + None + }; + let devnet_app_context = if saved_network == Network::Devnet { + make_context(Network::Devnet) + } else { + None + }; + let local_app_context = if saved_network == Network::Regtest { + make_context(Network::Regtest) + } else { + None + }; + + // Store params for lazy context creation when switching networks later. + let lazy_ctx_params = Some(LazyContextParams { data_dir, - Network::Regtest, - db.clone(), + db: db.clone(), password_info, - subtasks.clone(), - connection_status.clone(), - ctx.clone(), - ); + subtasks: subtasks.clone(), + connection_status: connection_status.clone(), + egui_ctx: ctx.clone(), + }); // load fonts ctx.set_fonts(crate::bundled::fonts().expect("failed to load fonts")); @@ -787,6 +809,7 @@ impl AppState { testnet_app_context, devnet_app_context, local_app_context, + lazy_ctx_params, mainnet_core_zmq_listener, testnet_core_zmq_listener, devnet_core_zmq_listener, @@ -976,12 +999,33 @@ impl AppState { } pub fn change_network(&mut self, network: Network) { + // Lazily create the AppContext if this network was deferred at startup. if !self.context_available_for_network(network) { - tracing::error!( - "Cannot switch to {:?}: network context not available. Staying on current network.", - network - ); - return; + if let Some(ref params) = self.lazy_ctx_params { + let ctx = AppContext::new( + params.data_dir.clone(), + network, + params.db.clone(), + params.password_info.clone(), + params.subtasks.clone(), + params.connection_status.clone(), + params.egui_ctx.clone(), + ); + match network { + Network::Testnet => self.testnet_app_context = ctx, + Network::Devnet => self.devnet_app_context = ctx, + Network::Regtest => self.local_app_context = ctx, + _ => {} + } + } + + if !self.context_available_for_network(network) { + tracing::error!( + "Cannot switch to {:?}: network context not available. Staying on current network.", + network + ); + return; + } } self.chosen_network = network; From b305ad546df0df13a69b6b50f9838f6e986dbc97 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:24:08 +0200 Subject: [PATCH 06/48] refactor(dapi_discovery): replace String errors with typed DapiDiscoveryError Create DapiDiscoveryError enum with typed variants for each failure mode (InvalidAddresses, AddressesRequired, NoRuntime, ProviderInit, Timeout, RequestFailed, NoResults). Wire into TaskError via #[from]. All user-facing messages follow the "what happened + what to do" pattern. Fixes: PROJ-001 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend_task/error.rs | 4 +++ src/context/mod.rs | 3 +- src/dapi_discovery.rs | 65 ++++++++++++++++++++++++++++++++------- 3 files changed, 59 insertions(+), 13 deletions(-) diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index d7b3ab8dd..42df3946b 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -54,6 +54,10 @@ pub enum TaskError { #[error("An internal operation failed unexpectedly. Please restart the application.")] JoinError(#[from] tokio::task::JoinError), + /// DAPI node discovery or address resolution failed. + #[error(transparent)] + DapiDiscovery(#[from] crate::dapi_discovery::DapiDiscoveryError), + /// Core wallet not configured for this wallet on a multi-wallet Core node. #[error( "Core wallet not configured for this wallet. Go to the Wallets screen and refresh to auto-detect the Core wallet association." diff --git a/src/context/mod.rs b/src/context/mod.rs index cebff40b5..8da109ae9 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -565,8 +565,7 @@ impl AppContext { self.network, &cfg.dapi_addresses, cfg.devnet_name.as_deref(), - ) - .map_err(|e| TaskError::SdkInitializationFailed { detail: e })?; + )?; let new_sdk = match self.core_backend_mode() { CoreBackendMode::Spv => { diff --git a/src/dapi_discovery.rs b/src/dapi_discovery.rs index 51a9186b0..f08d94dcc 100644 --- a/src/dapi_discovery.rs +++ b/src/dapi_discovery.rs @@ -21,13 +21,56 @@ //! Results are not cached across restarts; discovery runs once per //! `AppContext` initialization for the active network. -use dash_sdk::dapi_client::{Address as DapiAddress, AddressList}; +use dash_sdk::dapi_client::{Address as DapiAddress, AddressList, AddressListError}; use dash_sdk::dpp::dashcore::Network; -use rs_sdk_trusted_context_provider::TrustedHttpContextProvider; +use rs_sdk_trusted_context_provider::{TrustedContextProviderError, TrustedHttpContextProvider}; use std::num::NonZeroUsize; use std::str::FromStr; use std::time::Duration; +/// Errors from DAPI address resolution and discovery. +#[derive(Debug, thiserror::Error)] +pub enum DapiDiscoveryError { + /// Explicitly configured DAPI addresses could not be parsed. + #[error("Could not parse the configured node addresses. Check the DAPI addresses in your network settings.")] + InvalidAddresses { + #[source] + source: AddressListError, + }, + + /// Network requires explicit DAPI addresses in `.env`. + #[error( + "Node addresses must be configured for this network. Add DAPI addresses in your network settings." + )] + AddressesRequired { network: Network }, + + /// No tokio runtime available for async discovery. + #[error("The application could not start node discovery. Please restart the application.")] + NoRuntime, + + /// The trusted discovery provider could not be initialized. + #[error("Could not start node discovery. Check your network connection and retry.")] + ProviderInit { + #[source] + source: TrustedContextProviderError, + }, + + /// Discovery HTTP request timed out. + #[error("Node discovery timed out. Check your network connection and retry.")] + Timeout, + + /// Discovery HTTP request failed. + #[error("Node discovery failed. Check your network connection and retry.")] + RequestFailed { + #[source] + source: TrustedContextProviderError, + }, + + /// Discovery succeeded but returned no usable addresses. + #[error("No available nodes were found. The network may be temporarily unavailable -- try again later.")] + NoResults, +} + /// Resolve DAPI addresses synchronously. /// /// If explicit addresses are configured, parses them directly. For mainnet and @@ -38,46 +81,46 @@ pub fn resolve_dapi_addresses_sync( network: Network, dapi_addresses: &Option, devnet_name: Option<&str>, -) -> Result { +) -> Result { if let Some(addrs) = dapi_addresses { let trimmed = addrs.trim(); if !trimmed.is_empty() { return AddressList::from_str(trimmed) - .map_err(|e| format!("Invalid DAPI addresses: {e}")); + .map_err(|source| DapiDiscoveryError::InvalidAddresses { source }); } } match network { Network::Mainnet | Network::Testnet => {} - _ => return Err("DAPI addresses must be configured in .env for this network".into()), + _ => return Err(DapiDiscoveryError::AddressesRequired { network }), } match tokio::runtime::Handle::try_current() { Ok(handle) => tokio::task::block_in_place(|| { handle.block_on(try_discover_nodes(network, devnet_name)) }), - Err(_) => Err("No tokio runtime available for dynamic DAPI discovery".to_string()), + Err(_) => Err(DapiDiscoveryError::NoRuntime), } } async fn try_discover_nodes( network: Network, devnet_name: Option<&str>, -) -> Result { +) -> Result { let provider = TrustedHttpContextProvider::new( network, devnet_name.map(|s| s.to_string()), NonZeroUsize::new(10).unwrap(), ) - .map_err(|e| format!("Could not initialize node discovery: {e}"))?; + .map_err(|source| DapiDiscoveryError::ProviderInit { source })?; let urls = tokio::time::timeout( Duration::from_secs(10), provider.fetch_masternode_addresses(), ) .await - .map_err(|_| "Node discovery timed out after 10 seconds".to_string())? - .map_err(|e| format!("Node discovery request failed: {e}"))?; + .map_err(|_| DapiDiscoveryError::Timeout)? + .map_err(|source| DapiDiscoveryError::RequestFailed { source })?; let mut address_list = AddressList::new(); for url in urls { @@ -90,7 +133,7 @@ async fn try_discover_nodes( } if address_list.is_empty() { - return Err("Dynamic discovery returned no valid DAPI addresses".to_string()); + return Err(DapiDiscoveryError::NoResults); } tracing::info!( From 7b259385a38cacbeb3a2aeaf4f99361aaa2976fa Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:30:15 +0200 Subject: [PATCH 07/48] style: format dapi_discovery.rs Co-Authored-By: Claude Opus 4.6 (1M context) --- src/dapi_discovery.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/dapi_discovery.rs b/src/dapi_discovery.rs index fd6cf55aa..9c69da154 100644 --- a/src/dapi_discovery.rs +++ b/src/dapi_discovery.rs @@ -32,7 +32,9 @@ use std::time::Duration; #[derive(Debug, thiserror::Error)] pub enum DapiDiscoveryError { /// Explicitly configured DAPI addresses could not be parsed. - #[error("Could not parse the configured node addresses. Check the DAPI addresses in your network settings.")] + #[error( + "Could not parse the configured node addresses. Check the DAPI addresses in your network settings." + )] InvalidAddresses { #[source] source: AddressListError, @@ -67,7 +69,9 @@ pub enum DapiDiscoveryError { }, /// Discovery succeeded but returned no usable addresses. - #[error("No available nodes were found. The network may be temporarily unavailable -- try again later.")] + #[error( + "No available nodes were found. The network may be temporarily unavailable -- try again later." + )] NoResults, } From 2504126547a0831137e770dd1d8dde0631bb07fe Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:25:41 +0200 Subject: [PATCH 08/48] fix(dapi_discovery): fall back to seed nodes when dynamic discovery fails When DAPI discovery times out or fails for mainnet/testnet, use hardcoded fallback seed nodes instead of propagating the error. This ensures the app can always start -- discovery failure is no longer fatal for networks with known seed nodes. Fixes: CODE-002 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/dapi_discovery.rs | 68 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 3 deletions(-) diff --git a/src/dapi_discovery.rs b/src/dapi_discovery.rs index f08d94dcc..fd6cf55aa 100644 --- a/src/dapi_discovery.rs +++ b/src/dapi_discovery.rs @@ -71,12 +71,35 @@ pub enum DapiDiscoveryError { NoResults, } +/// Hardcoded fallback seed nodes for mainnet (a small subset of known-good +/// masternodes). Used only when dynamic discovery fails so the app can still +/// start in a degraded state. +const MAINNET_SEED_NODES: &[&str] = &[ + "https://52.33.9.172:443", + "https://134.255.182.186:443", + "https://168.119.102.10:443", + "https://65.108.246.145:443", + "https://37.27.83.17:443", + "https://135.181.110.216:443", +]; + +/// Hardcoded fallback seed nodes for testnet. +const TESTNET_SEED_NODES: &[&str] = &[ + "https://34.214.48.68:1443", + "https://52.12.176.90:1443", + "https://52.34.144.50:1443", + "https://44.240.98.102:1443", + "https://54.201.32.131:1443", + "https://52.10.229.11:1443", +]; + /// Resolve DAPI addresses synchronously. /// /// If explicit addresses are configured, parses them directly. For mainnet and /// testnet without explicit config, attempts dynamic discovery via -/// `tokio::task::block_in_place` (requires a tokio runtime). Returns an error -/// if discovery fails or no runtime is available. +/// `tokio::task::block_in_place` (requires a tokio runtime). When discovery +/// fails for mainnet or testnet, falls back to hardcoded seed nodes so the +/// app can still start. Returns an error only if all resolution methods fail. pub fn resolve_dapi_addresses_sync( network: Network, dapi_addresses: &Option, @@ -95,12 +118,51 @@ pub fn resolve_dapi_addresses_sync( _ => return Err(DapiDiscoveryError::AddressesRequired { network }), } - match tokio::runtime::Handle::try_current() { + let discovery_result = match tokio::runtime::Handle::try_current() { Ok(handle) => tokio::task::block_in_place(|| { handle.block_on(try_discover_nodes(network, devnet_name)) }), Err(_) => Err(DapiDiscoveryError::NoRuntime), + }; + + match discovery_result { + Ok(list) => Ok(list), + Err(e) => { + tracing::warn!( + ?network, + error = %e, + "Dynamic DAPI discovery failed, falling back to seed nodes" + ); + fallback_seed_nodes(network) + } + } +} + +/// Build an `AddressList` from hardcoded seed nodes for the given network. +fn fallback_seed_nodes(network: Network) -> Result { + let seeds = match network { + Network::Mainnet => MAINNET_SEED_NODES, + Network::Testnet => TESTNET_SEED_NODES, + _ => return Err(DapiDiscoveryError::AddressesRequired { network }), + }; + + let mut list = AddressList::new(); + for seed in seeds { + if let Ok(addr) = DapiAddress::from_str(seed) { + list.add(addr); + } } + + if list.is_empty() { + return Err(DapiDiscoveryError::NoResults); + } + + tracing::info!( + ?network, + count = list.len(), + "Using fallback seed nodes for DAPI" + ); + Ok(list) } async fn try_discover_nodes( From 9dc7b30f53f9c6844ba9d14460b7c0d6bb2ea849 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:32:33 +0200 Subject: [PATCH 09/48] fix(config): use stderr for migration logging before logger init Migration runs before initialize_logger(), so tracing output is silently dropped. Replace tracing::warn! calls with eprintln! to ensure migration errors are visible on stderr. Co-Authored-By: Claude Opus 4.6 --- src/config.rs | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/config.rs b/src/config.rs index d6ecb5f81..4d447bd91 100644 --- a/src/config.rs +++ b/src/config.rs @@ -295,7 +295,7 @@ impl Config { let env_file_path = match app_user_data_file_path(".env") { Ok(p) => p, Err(e) => { - tracing::warn!(error = %e, "Could not determine .env file path for migration"); + eprintln!("[migration] Could not determine .env file path: {e}"); return; } }; @@ -303,7 +303,10 @@ impl Config { let content = match std::fs::read_to_string(&env_file_path) { Ok(c) => c, Err(e) => { - tracing::warn!(error = %e, path = ?env_file_path, "Could not read .env file for migration"); + eprintln!( + "[migration] Could not read .env file at {}: {e}", + env_file_path.display() + ); return; } }; @@ -340,7 +343,10 @@ impl Config { let parent_dir = match env_file_path.parent() { Some(p) => p, None => { - tracing::warn!(path = ?env_file_path, "No parent directory for .env file during migration"); + eprintln!( + "[migration] No parent directory for .env file at {}", + env_file_path.display() + ); return; } }; @@ -353,20 +359,20 @@ impl Config { let mut tmp = match NamedTempFile::new_in(parent_dir) { Ok(t) => t, Err(e) => { - tracing::warn!(error = %e, "Failed to create temp file for .env migration"); + eprintln!("[migration] Failed to create temp file for .env migration: {e}"); return; } }; if let Err(e) = tmp.write_all(new_content.as_bytes()) { - tracing::warn!(error = %e, "Failed to write migrated .env file"); + eprintln!("[migration] Failed to write migrated .env file: {e}"); return; } if let Err(e) = tmp.as_file().sync_all() { - tracing::warn!(error = %e, "Failed to sync migrated .env file"); + eprintln!("[migration] Failed to sync migrated .env file: {e}"); return; } if let Err(e) = tmp.persist(&env_file_path) { - tracing::warn!(error = %e, "Failed to persist migrated .env file"); + eprintln!("[migration] Failed to persist migrated .env file: {e}"); return; } From 3d3847ccc83d29f4208bbfebcb8827e08d2adaaf Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:10:43 +0200 Subject: [PATCH 10/48] refactor(app): make mainnet_app_context optional like other networks Only create an AppContext for the user's chosen_network at startup. All other networks (including mainnet when not chosen) are deferred and created lazily when the user switches to them. This eliminates mainnet DAPI discovery at startup for testnet/devnet users. Key changes: - AppState::mainnet_app_context is now Option> - Screen constructors use active_context instead of per-network if-else - NetworkChooserScreen stores data_dir and db separately for shared access - change_context() for NetworkChooserScreen now stores the new context in the correct network slot (fixes pre-existing lazy context bug) - ZMQ listener setup handles optional mainnet context - MCP server init simplified to use active_context directly Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app.rs | 420 +++++++++---------------------- src/ui/mod.rs | 13 +- src/ui/network_chooser_screen.rs | 248 +++++++++--------- 3 files changed, 250 insertions(+), 431 deletions(-) diff --git a/src/app.rs b/src/app.rs index 608416173..8676c7d87 100644 --- a/src/app.rs +++ b/src/app.rs @@ -83,7 +83,7 @@ pub struct AppState { pub screen_stack: Vec, pub chosen_network: Network, pub connection_status: Arc, - pub mainnet_app_context: Arc, + pub mainnet_app_context: Option>, pub testnet_app_context: Option>, pub devnet_app_context: Option>, pub local_app_context: Option>, @@ -270,28 +270,27 @@ impl AppState { ) }; - // Always create mainnet eagerly (required, non-optional). - let mainnet_app_context = make_context(Network::Mainnet) - .ok_or("Failed to create AppContext for mainnet. Check your Dash configuration.")?; - - // Only create the saved/active network eagerly; defer others until the - // user switches to them. This avoids DAPI discovery + SDK init for - // networks the user may never use, eliminating potential startup delays. - let testnet_app_context = if saved_network == Network::Testnet { - make_context(Network::Testnet) - } else { - None - }; - let devnet_app_context = if saved_network == Network::Devnet { - make_context(Network::Devnet) - } else { - None - }; - let local_app_context = if saved_network == Network::Regtest { - make_context(Network::Regtest) - } else { - None - }; + // 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. + let active_context = make_context(saved_network).ok_or_else(|| { + format!( + "Failed to create AppContext for {:?}. Check your Dash configuration.", + saved_network + ) + })?; + + let mut mainnet_app_context = None; + let mut testnet_app_context = None; + let mut devnet_app_context = None; + let mut local_app_context = None; + match saved_network { + Network::Mainnet => mainnet_app_context = Some(active_context.clone()), + Network::Testnet => testnet_app_context = Some(active_context.clone()), + Network::Devnet => devnet_app_context = Some(active_context.clone()), + Network::Regtest => local_app_context = Some(active_context.clone()), + _ => {} + } // Store params for lazy context creation when switching networks later. let lazy_ctx_params = Some(LazyContextParams { @@ -318,199 +317,51 @@ 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, + // 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( + mainnet_app_context.as_ref(), testnet_app_context.as_ref(), devnet_app_context.as_ref(), local_app_context.as_ref(), - Network::Mainnet, + saved_network, overwrite_dash_conf, ); - let mut masternode_list_diff_screen = MasternodeListDiffScreen::new(&mainnet_app_context); + let masternode_list_diff_screen = MasternodeListDiffScreen::new(&active_context); - let mut wallets_balances_screen = WalletsBalancesScreen::new(&mainnet_app_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()); - } + let chosen_network = saved_network; // // Create a channel with a buffer size of 32 (adjust as needed) let (task_result_sender, task_result_receiver) = @@ -520,25 +371,28 @@ 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 { + let mainnet_core_zmq_listener = mainnet_app_context.as_ref().and_then(|ctx| { + let endpoint = ctx + .config + .read() + .unwrap() + .core_zmq_endpoint + .clone() + .unwrap_or_else(|| "tcp://127.0.0.1:23708".to_string()); + let disable_zmq = ctx + .get_settings() + .ok() + .flatten() + .map(|s| s.disable_zmq) + .unwrap_or(false); + if disable_zmq { + return None; + } match CoreZMQListener::spawn_listener( Network::Mainnet, - &mainnet_core_zmq_endpoint, + &endpoint, core_message_sender.clone(), - Some(mainnet_app_context.sx_zmq_status.clone()), + Some(ctx.sx_zmq_status.clone()), ) { Ok(listener) => Some(listener), Err(e) => { @@ -549,9 +403,7 @@ impl AppState { None } } - } else { - None - }; + }); let testnet_tx_zmq_status_option = testnet_app_context .as_ref() @@ -656,25 +508,7 @@ impl AppState { #[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(); @@ -837,10 +671,10 @@ 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. @@ -882,50 +716,42 @@ 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) - } - if let Some(context) = self.local_app_context.as_ref() { - context.enable_animations(enabled) + for context in [ + &self.mainnet_app_context, + &self.testnet_app_context, + &self.devnet_app_context, + &self.local_app_context, + ] + .into_iter() + .flatten() + { + 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 { + let opt = 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(|| { - panic!( - "BUG: chosen network is Regtest but local_app_context is missing; refusing silent mainnet fallback" - ) - }), + Network::Testnet => &self.testnet_app_context, + Network::Devnet => &self.devnet_app_context, + Network::Regtest => &self.local_app_context, unsupported_network => panic!( - "BUG: unsupported network variant {:?} in current_app_context; refusing silent mainnet fallback", + "BUG: unsupported network variant {:?} in current_app_context", unsupported_network ), - } + }; + opt.as_ref().unwrap_or_else(|| { + panic!( + "BUG: chosen network is {:?} but its AppContext is missing", + self.chosen_network + ) + }) } fn context_available_for_network(&self, network: Network) -> bool { match network { - Network::Mainnet => true, // Mainnet is always available + Network::Mainnet => self.mainnet_app_context.is_some(), Network::Testnet => self.testnet_app_context.is_some(), Network::Devnet => self.devnet_app_context.is_some(), Network::Regtest => self.local_app_context.is_some(), @@ -1012,6 +838,7 @@ impl AppState { params.egui_ctx.clone(), ); match network { + Network::Mainnet => self.mainnet_app_context = ctx, Network::Testnet => self.testnet_app_context = ctx, Network::Devnet => self.devnet_app_context = ctx, Network::Regtest => self.local_app_context = ctx, @@ -1434,33 +1261,16 @@ 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 ctx_opt = match network { + Network::Mainnet => self.mainnet_app_context.as_ref(), + Network::Testnet => self.testnet_app_context.as_ref(), + Network::Devnet => self.devnet_app_context.as_ref(), + Network::Regtest => self.local_app_context.as_ref(), + _ => None, + }; + let Some(app_context) = ctx_opt else { + tracing::error!("No app context available for {:?}", network); + continue; }; match message { ZMQMessage::ISLockedTransaction(tx, is_lock) => { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 08b6cd90a..f21011217 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -51,6 +51,7 @@ use contracts_documents::add_contracts_screen::AddContractsScreen; use contracts_documents::group_actions_screen::GroupActionsScreen; use contracts_documents::register_contract_screen::RegisterDataContractScreen; use contracts_documents::update_contract_screen::UpdateDataContractScreen; +use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::identity::Identity; use dash_sdk::dpp::prelude::IdentityPublicKey; use dash_sdk::platform::Identifier; @@ -780,7 +781,17 @@ impl Screen { 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::NetworkChooserScreen(screen) => { + let network = app_context.network; + match network { + Network::Mainnet => screen.mainnet_app_context = Some(app_context), + Network::Testnet => screen.testnet_app_context = Some(app_context), + Network::Devnet => screen.devnet_app_context = Some(app_context), + Network::Regtest => screen.local_app_context = Some(app_context), + _ => {} + } + screen.current_network = 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, diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index 175fcc639..ea5165f2d 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -83,10 +83,14 @@ fn add_dapi_status_label( } pub struct NetworkChooserScreen { - pub mainnet_app_context: Arc, + pub mainnet_app_context: Option>, pub testnet_app_context: Option>, pub devnet_app_context: Option>, pub local_app_context: Option>, + /// 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, @@ -114,29 +118,45 @@ pub struct NetworkChooserScreen { impl NetworkChooserScreen { pub fn new( - mainnet_app_context: &Arc, + mainnet_app_context: Option<&Arc>, testnet_app_context: Option<&Arc>, devnet_app_context: Option<&Arc>, local_app_context: Option<&Arc>, current_network: Network, overwrite_dash_conf: bool, ) -> Self { + // Pick whichever context is available for the current network; fall back + // to any available context. At least one must exist at startup. + let any_context = [ + mainnet_app_context, + testnet_app_context, + devnet_app_context, + local_app_context, + ] + .into_iter() + .flatten() + .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()); } 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, + Network::Mainnet => mainnet_app_context.unwrap_or(any_context), + Network::Testnet => testnet_app_context.unwrap_or(any_context), + Network::Devnet => devnet_app_context.unwrap_or(any_context), + Network::Regtest => local_app_context.unwrap_or(any_context), + _ => any_context, }; let developer_mode = current_context.is_developer_mode(); @@ -149,18 +169,17 @@ 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( + Network::Mainnet, + mainnet_app_context + .map(|ctx| ctx.core_backend_mode()) + .unwrap_or_default(), + ); backend_modes.insert( Network::Testnet, testnet_app_context @@ -181,10 +200,12 @@ impl NetworkChooserScreen { ); Self { - mainnet_app_context: mainnet_app_context.clone(), + mainnet_app_context: mainnet_app_context.cloned(), testnet_app_context: testnet_app_context.cloned(), devnet_app_context: devnet_app_context.cloned(), local_app_context: local_app_context.cloned(), + data_dir, + db, dashmate_password_input, current_network, recheck_time: None, @@ -211,24 +232,31 @@ impl NetworkChooserScreen { } } - pub fn context_for_network(&self, network: Network) -> &Arc { + pub fn context_for_network(&self, network: Network) -> Option<&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, + Network::Mainnet => self.mainnet_app_context.as_ref(), + Network::Testnet => self.testnet_app_context.as_ref(), + Network::Devnet => self.devnet_app_context.as_ref(), + Network::Regtest => self.local_app_context.as_ref(), + _ => None, } } + /// 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.mainnet_app_context, + &self.testnet_app_context, + &self.devnet_app_context, + &self.local_app_context, + ] + .into_iter() + .find_map(|o| o.as_ref()) + }) + .expect("BUG: no AppContext available for any network") } /// Save the current settings to the database @@ -417,7 +445,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| { @@ -484,7 +512,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() { @@ -496,7 +524,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 @@ -511,37 +539,30 @@ impl NetworkChooserScreen { // 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); + let reinit_failed = + 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; - } + { + 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 + 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 + } } else { false - } - } else { - false - }; + }; match (save_failed, reinit_failed) { (false, false) => { @@ -1132,22 +1153,22 @@ impl NetworkChooserScreen { .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.mainnet_app_context, + &self.testnet_app_context, + &self.devnet_app_context, + &self.local_app_context, + ] + .into_iter() + .flatten() + { 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}"); } } @@ -1156,32 +1177,19 @@ impl NetworkChooserScreen { // Remove this block once SPV is production-ready. if !self.developer_mode { // Stop SPV and switch to RPC for all network contexts - self.mainnet_app_context.stop_spv(); - if self.mainnet_app_context.core_backend_mode() == CoreBackendMode::Spv { - self.mainnet_app_context.set_core_backend_mode(CoreBackendMode::Rpc); - } - self.backend_modes.insert(Network::Mainnet, CoreBackendMode::Rpc); - - if let Some(ref ctx) = self.testnet_app_context { - ctx.stop_spv(); - if ctx.core_backend_mode() == CoreBackendMode::Spv { - ctx.set_core_backend_mode(CoreBackendMode::Rpc); - } - self.backend_modes.insert(Network::Testnet, CoreBackendMode::Rpc); - } - if let Some(ref ctx) = self.devnet_app_context { - ctx.stop_spv(); - if ctx.core_backend_mode() == CoreBackendMode::Spv { - ctx.set_core_backend_mode(CoreBackendMode::Rpc); - } - self.backend_modes.insert(Network::Devnet, CoreBackendMode::Rpc); - } - if let Some(ref ctx) = self.local_app_context { - ctx.stop_spv(); - if ctx.core_backend_mode() == CoreBackendMode::Spv { - ctx.set_core_backend_mode(CoreBackendMode::Rpc); + for (network, ctx_opt) in [ + (Network::Mainnet, &self.mainnet_app_context), + (Network::Testnet, &self.testnet_app_context), + (Network::Devnet, &self.devnet_app_context), + (Network::Regtest, &self.local_app_context), + ] { + if let Some(ctx) = ctx_opt { + 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); } } } @@ -1275,7 +1283,6 @@ impl NetworkChooserScreen { { // Save to database match self - .mainnet_app_context .db .update_close_dash_qt_on_exit(self.close_dash_qt_on_exit) { @@ -1333,21 +1340,19 @@ 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.mainnet_app_context, + &self.testnet_app_context, + &self.devnet_app_context, + &self.local_app_context, + ] + .into_iter() + .flatten() + { ctx.spv_manager().set_use_local_node(self.use_local_spv_node); } } @@ -1397,7 +1402,6 @@ impl NetworkChooserScreen { { // Save to database let _ = self - .mainnet_app_context .db .update_auto_start_spv(self.auto_start_spv); } @@ -2127,21 +2131,15 @@ 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_opt) in [ + (Network::Mainnet, &self.mainnet_app_context), + (Network::Testnet, &self.testnet_app_context), + (Network::Devnet, &self.devnet_app_context), + (Network::Regtest, &self.local_app_context), + ] { + if let Some(ctx) = ctx_opt { + self.backend_modes.insert(network, ctx.core_backend_mode()); + } } } From 40df7e8c39aa942a348b51b5f2b996a69cf81022 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:43:44 +0200 Subject: [PATCH 11/48] refactor(app): simplify network context management and reduce boilerplate - Replace 4 per-network Option> fields with BTreeMap> - Replace 4 per-network ZMQ listener fields with BTreeMap - Extract spawn_zmq_listener() helper, eliminating 4x copy-paste (~100 lines) - Simplify change_context dispatch with set_ctx! macro (~40 trivial arms) - Extract set_main_screen() and try_auto_start_spv() helper methods - Remove 32-line commented-out start_listening_for_asset_locks dead code - Group theme fields into ThemeState sub-struct with poll_and_apply() - Update NetworkChooserScreen to accept &BTreeMap instead of 4 separate params Net: -274 lines of duplication and boilerplate. Co-Authored-By: Claude Opus 4.6 --- src/app.rs | 520 ++++++++++--------------------- src/ui/mod.rs | 148 +++++---- src/ui/network_chooser_screen.rs | 196 +++--------- 3 files changed, 295 insertions(+), 569 deletions(-) diff --git a/src/app.rs b/src/app.rs index 8676c7d87..5cbdbd6a6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -77,33 +77,82 @@ struct LazyContextParams { egui_ctx: egui::Context, } +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: Option>, - pub testnet_app_context: Option>, - pub devnet_app_context: Option>, - pub local_app_context: Option>, + pub network_contexts: BTreeMap>, /// Params kept for lazy AppContext creation when switching networks. lazy_ctx_params: 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, - #[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, 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 @@ -280,17 +329,8 @@ impl AppState { ) })?; - let mut mainnet_app_context = None; - let mut testnet_app_context = None; - let mut devnet_app_context = None; - let mut local_app_context = None; - match saved_network { - Network::Mainnet => mainnet_app_context = Some(active_context.clone()), - Network::Testnet => testnet_app_context = Some(active_context.clone()), - Network::Devnet => devnet_app_context = Some(active_context.clone()), - Network::Regtest => local_app_context = Some(active_context.clone()), - _ => {} - } + let mut network_contexts = BTreeMap::new(); + network_contexts.insert(saved_network, active_context.clone()); // Store params for lazy context creation when switching networks later. let lazy_ctx_params = Some(LazyContextParams { @@ -347,14 +387,8 @@ impl AppState { DashPayScreen::new(&active_context, DashPaySubscreen::Payments); let dashpay_profile_search_screen = ProfileSearchScreen::new(active_context.clone()); - let network_chooser_screen = NetworkChooserScreen::new( - mainnet_app_context.as_ref(), - testnet_app_context.as_ref(), - devnet_app_context.as_ref(), - local_app_context.as_ref(), - saved_network, - overwrite_dash_conf, - ); + let network_chooser_screen = + NetworkChooserScreen::new(&network_contexts, saved_network, overwrite_dash_conf); let masternode_list_diff_screen = MasternodeListDiffScreen::new(&active_context); @@ -371,138 +405,13 @@ impl AppState { let (core_message_sender, core_message_receiver) = mpsc::channel().with_egui_ctx(ctx.clone()); - let mainnet_core_zmq_listener = mainnet_app_context.as_ref().and_then(|ctx| { - let endpoint = ctx - .config - .read() - .unwrap() - .core_zmq_endpoint - .clone() - .unwrap_or_else(|| "tcp://127.0.0.1:23708".to_string()); - let disable_zmq = ctx - .get_settings() - .ok() - .flatten() - .map(|s| s.disable_zmq) - .unwrap_or(false); - if disable_zmq { - return None; - } - match CoreZMQListener::spawn_listener( - Network::Mainnet, - &endpoint, - core_message_sender.clone(), - Some(ctx.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 - } - } - }); - - 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")] @@ -639,22 +548,13 @@ impl AppState { screen_stack: vec![], chosen_network, connection_status, - mainnet_app_context, - testnet_app_context, - devnet_app_context, - local_app_context, + network_contexts, lazy_ctx_params, - mainnet_core_zmq_listener, - testnet_core_zmq_listener, - devnet_core_zmq_listener, - local_core_zmq_listener, + zmq_listeners, 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, @@ -676,22 +576,7 @@ impl AppState { app_state.welcome_screen = 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 @@ -716,47 +601,25 @@ impl AppState { /// /// Default is enabled. pub fn with_animations(self, enabled: bool) -> Self { - for context in [ - &self.mainnet_app_context, - &self.testnet_app_context, - &self.devnet_app_context, - &self.local_app_context, - ] - .into_iter() - .flatten() - { + for context in self.network_contexts.values() { context.enable_animations(enabled); } self } pub fn current_app_context(&self) -> &Arc { - let opt = match self.chosen_network { - Network::Mainnet => &self.mainnet_app_context, - Network::Testnet => &self.testnet_app_context, - Network::Devnet => &self.devnet_app_context, - Network::Regtest => &self.local_app_context, - unsupported_network => panic!( - "BUG: unsupported network variant {:?} in current_app_context", - unsupported_network - ), - }; - opt.as_ref().unwrap_or_else(|| { - panic!( - "BUG: chosen network is {:?} but its AppContext is missing", - self.chosen_network - ) - }) + self.network_contexts + .get(&self.chosen_network) + .unwrap_or_else(|| { + panic!( + "BUG: chosen network is {:?} but its AppContext is missing", + self.chosen_network + ) + }) } fn context_available_for_network(&self, network: Network) -> bool { - match network { - Network::Mainnet => self.mainnet_app_context.is_some(), - 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) { @@ -818,6 +681,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) @@ -827,8 +728,8 @@ impl AppState { pub fn change_network(&mut self, network: Network) { // Lazily create the AppContext if this network was deferred at startup. if !self.context_available_for_network(network) { - if let Some(ref params) = self.lazy_ctx_params { - let ctx = AppContext::new( + if let Some(ref params) = self.lazy_ctx_params + && let Some(ctx) = AppContext::new( params.data_dir.clone(), network, params.db.clone(), @@ -836,14 +737,9 @@ impl AppState { params.subtasks.clone(), params.connection_status.clone(), params.egui_ctx.clone(), - ); - match network { - Network::Mainnet => self.mainnet_app_context = ctx, - Network::Testnet => self.testnet_app_context = ctx, - Network::Devnet => self.devnet_app_context = ctx, - Network::Regtest => self.local_app_context = ctx, - _ => {} - } + ) + { + self.network_contexts.insert(network, ctx); } if !self.context_available_for_network(network) { @@ -978,41 +874,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 { @@ -1057,10 +943,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; } @@ -1103,23 +986,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(); @@ -1160,22 +1027,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, @@ -1261,14 +1113,7 @@ 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 ctx_opt = match network { - Network::Mainnet => self.mainnet_app_context.as_ref(), - Network::Testnet => self.testnet_app_context.as_ref(), - Network::Devnet => self.devnet_app_context.as_ref(), - Network::Regtest => self.local_app_context.as_ref(), - _ => None, - }; - let Some(app_context) = ctx_opt else { + let Some(app_context) = self.network_contexts.get(&network) else { tracing::error!("No app context available for {:?}", network); continue; }; @@ -1429,26 +1274,14 @@ 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(); } @@ -1461,11 +1294,7 @@ impl App for AppState { } 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 { @@ -1474,29 +1303,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/ui/mod.rs b/src/ui/mod.rs index f21011217..88e6bb1fb 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -51,7 +51,6 @@ use contracts_documents::add_contracts_screen::AddContractsScreen; use contracts_documents::group_actions_screen::GroupActionsScreen; use contracts_documents::register_contract_screen::RegisterDataContractScreen; use contracts_documents::update_contract_screen::UpdateDataContractScreen; -use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::identity::Identity; use dash_sdk::dpp::prelude::IdentityPublicKey; use dash_sdk::platform::Identifier; @@ -772,127 +771,146 @@ pub enum Screen { impl Screen { pub fn change_context(&mut self, app_context: Arc) { + /// Assigns `app_context` for the majority of screen variants that simply + /// store it as a field. Only screens with additional side-effects are + /// handled in the explicit match arms below. + macro_rules! set_ctx { + ($($variant:ident),+ $(,)?) => { + match self { + $(Screen::$variant(screen) => screen.app_context = app_context,)+ + _ => {} + } + } + } + + // Screens with side-effects on context change are handled first. + // Everything else falls through to the macro default assignment. match self { - Screen::IdentitiesScreen(screen) => screen.app_context = app_context, - Screen::DPNSScreen(screen) => screen.app_context = app_context, - Screen::AddExistingIdentityScreen(screen) => screen.app_context = app_context, - Screen::KeyInfoScreen(screen) => screen.app_context = app_context, - Screen::KeysScreen(screen) => screen.app_context = app_context, - Screen::WithdrawalScreen(screen) => screen.app_context = app_context, - Screen::TransitionVisualizerScreen(screen) => screen.app_context = app_context, - Screen::ContractVisualizerScreen(screen) => screen.app_context = app_context, Screen::NetworkChooserScreen(screen) => { let network = app_context.network; - match network { - Network::Mainnet => screen.mainnet_app_context = Some(app_context), - Network::Testnet => screen.testnet_app_context = Some(app_context), - Network::Devnet => screen.devnet_app_context = Some(app_context), - Network::Regtest => screen.local_app_context = Some(app_context), - _ => {} - } + screen.network_contexts.insert(network, app_context); screen.current_network = network; + return; } - 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::AddNewWalletScreen(screen) => { screen.app_context = app_context; screen.reset_core_wallets_cache(); + return; } Screen::TransferScreen(screen) => { screen.app_context = app_context; screen.invalidate_address_input(); + return; } - Screen::TopUpIdentityScreen(screen) => screen.app_context = app_context, Screen::WalletsBalancesScreen(screen) => { screen.app_context = app_context; screen.reset_pending_list_state(); screen.update_selected_wallet_for_network(); screen.invalidate_address_inputs(); + return; } Screen::ImportMnemonicScreen(screen) => { screen.app_context = app_context; screen.reset_core_wallets_cache(); + return; } Screen::WalletSendScreen(screen) => { screen.app_context = app_context; screen.invalidate_address_input(); + return; } - Screen::SingleKeyWalletSendScreen(screen) => screen.app_context = app_context, - Screen::ProofLogScreen(screen) => screen.app_context = app_context, - Screen::AddContractsScreen(screen) => screen.app_context = app_context, - Screen::ProofVisualizerScreen(screen) => screen.app_context = app_context, Screen::MasternodeListDiffScreen(screen) => { let old_net = screen.app_context.network; if old_net != app_context.network { - // Switch context and clear state to avoid cross-network bleed screen.app_context = app_context.clone(); screen.clear(); } else { screen.app_context = app_context; } + return; } - Screen::DocumentVisualizerScreen(screen) => screen.app_context = app_context, - Screen::PlatformInfoScreen(screen) => screen.app_context = app_context, - Screen::GroveSTARKScreen(screen) => screen.app_context = app_context, Screen::AddressBalanceScreen(screen) => { screen.app_context = app_context; screen.invalidate_address_input(); + return; } - - // Token Screens - Screen::TokensScreen(screen) => screen.app_context = app_context, - Screen::TransferTokensScreen(screen) => screen.app_context = app_context, - Screen::MintTokensScreen(screen) => screen.app_context = app_context, - Screen::BurnTokensScreen(screen) => screen.app_context = app_context, - Screen::DestroyFrozenFundsScreen(screen) => screen.app_context = app_context, - Screen::FreezeTokensScreen(screen) => screen.app_context = app_context, - Screen::UnfreezeTokensScreen(screen) => screen.app_context = app_context, - Screen::PauseTokensScreen(screen) => screen.app_context = app_context, - Screen::ResumeTokensScreen(screen) => screen.app_context = app_context, - Screen::ClaimTokensScreen(screen) => screen.app_context = app_context, - Screen::ViewTokenClaimsScreen(screen) => screen.app_context = app_context, - Screen::UpdateTokenConfigScreen(screen) => screen.app_context = app_context, - Screen::AddTokenById(screen) => screen.app_context = app_context, - Screen::PurchaseTokenScreen(screen) => screen.app_context = app_context, - Screen::SetTokenPriceScreen(screen) => screen.app_context = app_context, - Screen::AssetLockDetailScreen(screen) => screen.app_context = app_context, - Screen::CreateAssetLockScreen(screen) => screen.app_context = app_context, - - // DashPay Screens Screen::DashPayScreen(screen) => { screen.app_context = app_context.clone(); screen.contacts_list.app_context = app_context.clone(); screen.contacts_list.contact_requests.app_context = app_context.clone(); screen.profile_screen.app_context = app_context.clone(); screen.payment_history.app_context = app_context; + return; } - Screen::DashPayAddContactScreen(screen) => screen.app_context = app_context, - Screen::DashPayContactDetailsScreen(screen) => screen.app_context = app_context, - Screen::DashPayContactProfileViewerScreen(screen) => screen.app_context = app_context, - Screen::DashPaySendPaymentScreen(screen) => screen.app_context = app_context, - Screen::DashPayContactInfoEditorScreen(screen) => screen.app_context = app_context, - Screen::DashPayQRGeneratorScreen(screen) => screen.app_context = app_context, - Screen::DashPayProfileSearchScreen(screen) => screen.app_context = app_context, - // Shielded screens Screen::ShieldScreen(screen) => { - screen.app_context = app_context.clone(); + screen.app_context = app_context; screen.invalidate_address_input(); + return; } Screen::ShieldedSendScreen(screen) => { - screen.app_context = app_context.clone(); + screen.app_context = app_context; screen.invalidate_address_input(); + return; } Screen::UnshieldCreditsScreen(screen) => { - screen.app_context = app_context.clone(); + screen.app_context = app_context; screen.invalidate_address_input(); + return; } + _ => {} } + + // Simple context assignment for all remaining screens. + set_ctx!( + IdentitiesScreen, + DPNSScreen, + AddExistingIdentityScreen, + KeyInfoScreen, + KeysScreen, + WithdrawalScreen, + TransitionVisualizerScreen, + ContractVisualizerScreen, + AddKeyScreen, + DocumentQueryScreen, + AddNewIdentityScreen, + RegisterDpnsNameScreen, + RegisterDataContractScreen, + UpdateDataContractScreen, + DocumentActionScreen, + GroupActionsScreen, + TopUpIdentityScreen, + SingleKeyWalletSendScreen, + ProofLogScreen, + AddContractsScreen, + ProofVisualizerScreen, + DocumentVisualizerScreen, + PlatformInfoScreen, + GroveSTARKScreen, + TokensScreen, + TransferTokensScreen, + MintTokensScreen, + BurnTokensScreen, + DestroyFrozenFundsScreen, + FreezeTokensScreen, + UnfreezeTokensScreen, + PauseTokensScreen, + ResumeTokensScreen, + ClaimTokensScreen, + ViewTokenClaimsScreen, + UpdateTokenConfigScreen, + AddTokenById, + PurchaseTokenScreen, + SetTokenPriceScreen, + AssetLockDetailScreen, + CreateAssetLockScreen, + DashPayAddContactScreen, + DashPayContactDetailsScreen, + DashPayContactProfileViewerScreen, + DashPaySendPaymentScreen, + DashPayContactInfoEditorScreen, + DashPayQRGeneratorScreen, + DashPayProfileSearchScreen, + ); } } diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index ea5165f2d..de5b88fc6 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -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,7 @@ fn add_dapi_status_label( } pub struct NetworkChooserScreen { - pub mainnet_app_context: Option>, - 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). @@ -118,25 +115,14 @@ pub struct NetworkChooserScreen { impl NetworkChooserScreen { pub fn new( - mainnet_app_context: Option<&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 { - // Pick whichever context is available for the current network; fall back - // to any available context. At least one must exist at startup. - let any_context = [ - mainnet_app_context, - testnet_app_context, - devnet_app_context, - local_app_context, - ] - .into_iter() - .flatten() - .next() - .expect("BUG: NetworkChooserScreen requires at least one AppContext"); + 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(); @@ -151,16 +137,9 @@ impl NetworkChooserScreen { dashmate_password_input.set_text(network_config.core_rpc_password.clone()); } - let current_context = match current_network { - Network::Mainnet => mainnet_app_context.unwrap_or(any_context), - Network::Testnet => testnet_app_context.unwrap_or(any_context), - Network::Devnet => devnet_app_context.unwrap_or(any_context), - Network::Regtest => local_app_context.unwrap_or(any_context), - _ => any_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() @@ -174,36 +153,23 @@ impl NetworkChooserScreen { 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( + for network in [ Network::Mainnet, - mainnet_app_context - .map(|ctx| ctx.core_backend_mode()) - .unwrap_or_default(), - ); - backend_modes.insert( 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.cloned(), - 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, @@ -233,29 +199,14 @@ impl NetworkChooserScreen { } pub fn context_for_network(&self, network: Network) -> Option<&Arc> { - match network { - Network::Mainnet => self.mainnet_app_context.as_ref(), - Network::Testnet => self.testnet_app_context.as_ref(), - Network::Devnet => self.devnet_app_context.as_ref(), - Network::Regtest => self.local_app_context.as_ref(), - _ => None, - } + 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.mainnet_app_context, - &self.testnet_app_context, - &self.devnet_app_context, - &self.local_app_context, - ] - .into_iter() - .find_map(|o| o.as_ref()) - }) + .or_else(|| self.network_contexts.values().next()) .expect("BUG: no AppContext available for any network") } @@ -410,38 +361,22 @@ impl NetworkChooserScreen { { app_action = AppAction::SwitchNetwork(Network::Mainnet); } - if self.testnet_app_context.is_some() - && ui - .selectable_value( - &mut self.current_network, - Network::Testnet, - "Testnet", - ) - .clicked() - { - app_action = AppAction::SwitchNetwork(Network::Testnet); - } - if self.devnet_app_context.is_some() - && ui - .selectable_value( - &mut self.current_network, - Network::Devnet, - "Devnet", - ) - .clicked() - { - app_action = AppAction::SwitchNetwork(Network::Devnet); - } - if self.local_app_context.is_some() - && ui - .selectable_value( - &mut self.current_network, - Network::Regtest, - "Local", - ) - .clicked() - { - app_action = AppAction::SwitchNetwork(Network::Regtest); + for (network, label) in [ + (Network::Testnet, "Testnet"), + (Network::Devnet, "Devnet"), + (Network::Regtest, "Local"), + ] { + if self.network_contexts.contains_key(&network) + && ui + .selectable_value( + &mut self.current_network, + network, + label, + ) + .clicked() + { + app_action = AppAction::SwitchNetwork(network); + } } if self.current_network != prev_network { let password = Config::load_from( @@ -1152,16 +1087,7 @@ impl NetworkChooserScreen { .clickable_tooltip("Show advanced options for power users and developers") .clicked() { - // Always update all contexts first to keep UI in sync - for ctx in [ - &self.mainnet_app_context, - &self.testnet_app_context, - &self.devnet_app_context, - &self.local_app_context, - ] - .into_iter() - .flatten() - { + for ctx in self.network_contexts.values() { ctx.enable_developer_mode(self.developer_mode); } @@ -1176,18 +1102,10 @@ 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 - for (network, ctx_opt) in [ - (Network::Mainnet, &self.mainnet_app_context), - (Network::Testnet, &self.testnet_app_context), - (Network::Devnet, &self.devnet_app_context), - (Network::Regtest, &self.local_app_context), - ] { - if let Some(ctx) = ctx_opt { - ctx.stop_spv(); - if ctx.core_backend_mode() == CoreBackendMode::Spv { - ctx.set_core_backend_mode(CoreBackendMode::Rpc); - } + 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, CoreBackendMode::Rpc); } @@ -1343,16 +1261,7 @@ impl NetworkChooserScreen { .db .update_use_local_spv_node(self.use_local_spv_node); - // Update all network contexts - for ctx in [ - &self.mainnet_app_context, - &self.testnet_app_context, - &self.devnet_app_context, - &self.local_app_context, - ] - .into_iter() - .flatten() - { + for ctx in self.network_contexts.values() { ctx.spv_manager().set_use_local_node(self.use_local_spv_node); } } @@ -2029,13 +1938,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 { @@ -2131,15 +2034,8 @@ impl ScreenLike for NetworkChooserScreen { self.theme_preference = settings.theme_mode; } - for (network, ctx_opt) in [ - (Network::Mainnet, &self.mainnet_app_context), - (Network::Testnet, &self.testnet_app_context), - (Network::Devnet, &self.devnet_app_context), - (Network::Regtest, &self.local_app_context), - ] { - if let Some(ctx) = ctx_opt { - self.backend_modes.insert(network, ctx.core_backend_mode()); - } + for (&network, ctx) in &self.network_contexts { + self.backend_modes.insert(network, ctx.core_backend_mode()); } } From 4fa63efd51edaa0cd3cd2ba998cd2202d50d30d4 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:30:15 +0200 Subject: [PATCH 12/48] style: format dapi_discovery.rs Co-Authored-By: Claude Opus 4.6 (1M context) --- src/dapi_discovery.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/dapi_discovery.rs b/src/dapi_discovery.rs index fd6cf55aa..9c69da154 100644 --- a/src/dapi_discovery.rs +++ b/src/dapi_discovery.rs @@ -32,7 +32,9 @@ use std::time::Duration; #[derive(Debug, thiserror::Error)] pub enum DapiDiscoveryError { /// Explicitly configured DAPI addresses could not be parsed. - #[error("Could not parse the configured node addresses. Check the DAPI addresses in your network settings.")] + #[error( + "Could not parse the configured node addresses. Check the DAPI addresses in your network settings." + )] InvalidAddresses { #[source] source: AddressListError, @@ -67,7 +69,9 @@ pub enum DapiDiscoveryError { }, /// Discovery succeeded but returned no usable addresses. - #[error("No available nodes were found. The network may be temporarily unavailable -- try again later.")] + #[error( + "No available nodes were found. The network may be temporarily unavailable -- try again later." + )] NoResults, } From e72c68750a9b424bf175218ff1804b4418f98e2f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:32:33 +0200 Subject: [PATCH 13/48] fix(config): use stderr for migration logging before logger init Migration runs before initialize_logger(), so tracing output is silently dropped. Replace tracing::warn! calls with eprintln! to ensure migration errors are visible on stderr. Co-Authored-By: Claude Opus 4.6 --- src/config.rs | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/config.rs b/src/config.rs index d6ecb5f81..4d447bd91 100644 --- a/src/config.rs +++ b/src/config.rs @@ -295,7 +295,7 @@ impl Config { let env_file_path = match app_user_data_file_path(".env") { Ok(p) => p, Err(e) => { - tracing::warn!(error = %e, "Could not determine .env file path for migration"); + eprintln!("[migration] Could not determine .env file path: {e}"); return; } }; @@ -303,7 +303,10 @@ impl Config { let content = match std::fs::read_to_string(&env_file_path) { Ok(c) => c, Err(e) => { - tracing::warn!(error = %e, path = ?env_file_path, "Could not read .env file for migration"); + eprintln!( + "[migration] Could not read .env file at {}: {e}", + env_file_path.display() + ); return; } }; @@ -340,7 +343,10 @@ impl Config { let parent_dir = match env_file_path.parent() { Some(p) => p, None => { - tracing::warn!(path = ?env_file_path, "No parent directory for .env file during migration"); + eprintln!( + "[migration] No parent directory for .env file at {}", + env_file_path.display() + ); return; } }; @@ -353,20 +359,20 @@ impl Config { let mut tmp = match NamedTempFile::new_in(parent_dir) { Ok(t) => t, Err(e) => { - tracing::warn!(error = %e, "Failed to create temp file for .env migration"); + eprintln!("[migration] Failed to create temp file for .env migration: {e}"); return; } }; if let Err(e) = tmp.write_all(new_content.as_bytes()) { - tracing::warn!(error = %e, "Failed to write migrated .env file"); + eprintln!("[migration] Failed to write migrated .env file: {e}"); return; } if let Err(e) = tmp.as_file().sync_all() { - tracing::warn!(error = %e, "Failed to sync migrated .env file"); + eprintln!("[migration] Failed to sync migrated .env file: {e}"); return; } if let Err(e) = tmp.persist(&env_file_path) { - tracing::warn!(error = %e, "Failed to persist migrated .env file"); + eprintln!("[migration] Failed to persist migrated .env file: {e}"); return; } From 8eac7ff056f23a750d454cd3ad7bed8386b769e6 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:59:23 +0200 Subject: [PATCH 14/48] =?UTF-8?q?fix(config):=20address=20PR=20review=20co?= =?UTF-8?q?mments=20=E2=80=94=20docs,=20dead=20code,=20migration=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove devnet discovery claim from dapi_discovery module docs (only Mainnet and Testnet support dynamic discovery) - Clarify dapi_addresses doc comment: devnet/regtest require explicit addresses - Delete dead NetworkConfig::is_valid() method and its tests - Accept &Path in migrate_env_file_if_needed instead of hardcoding path; callers in app.rs now pass data_dir.join(".env") Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app.rs | 4 +-- src/config.rs | 60 +++++-------------------------------------- src/dapi_discovery.rs | 4 ++- 3 files changed, 11 insertions(+), 57 deletions(-) diff --git a/src/app.rs b/src/app.rs index 608416173..5405b82c0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -216,7 +216,7 @@ impl AppState { let data_dir = app_user_data_dir_path()?; ensure_data_dir_exists(&data_dir)?; ensure_env_file(&data_dir); - crate::config::Config::migrate_env_file_if_needed(); + crate::config::Config::migrate_env_file_if_needed(&data_dir.join(".env")); initialize_logger(); let db_file_path = data_file_path(&data_dir, "data.db")?; let db = Arc::new(Database::new(&db_file_path)?); @@ -233,7 +233,7 @@ impl AppState { let data_dir = app_user_data_dir_path()?; ensure_data_dir_exists(&data_dir)?; ensure_env_file(&data_dir); - crate::config::Config::migrate_env_file_if_needed(); + crate::config::Config::migrate_env_file_if_needed(&data_dir.join(".env")); let db = Arc::new( crate::database::test_helpers::create_test_database() .map_err(|e| format!("Failed to create test database: {}", e))?, diff --git a/src/config.rs b/src/config.rs index 4d447bd91..055cf44e8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -56,6 +56,8 @@ pub enum ConfigError { pub struct NetworkConfig { /// Hostname of Dash Platform node to connect to. /// `None` or empty means dynamic discovery will be used. + /// Dynamic discovery is currently supported for Mainnet and Testnet only. + /// Devnet and Regtest require explicit addresses. pub dapi_addresses: Option, /// Host of the Dash Core RPC interface pub core_host: String, @@ -291,16 +293,8 @@ impl Config { /// Scans for `MAINNET_dapi_addresses=` and `TESTNET_dapi_addresses=` lines whose /// values match a known old hardcoded list. Matching lines are commented out so /// that `envy` will no longer parse them and dynamic discovery kicks in. - pub fn migrate_env_file_if_needed() { - let env_file_path = match app_user_data_file_path(".env") { - Ok(p) => p, - Err(e) => { - eprintln!("[migration] Could not determine .env file path: {e}"); - return; - } - }; - - let content = match std::fs::read_to_string(&env_file_path) { + pub fn migrate_env_file_if_needed(env_file_path: &Path) { + let content = match std::fs::read_to_string(env_file_path) { Ok(c) => c, Err(e) => { eprintln!( @@ -371,7 +365,7 @@ impl Config { eprintln!("[migration] Failed to sync migrated .env file: {e}"); return; } - if let Err(e) = tmp.persist(&env_file_path) { + if let Err(e) = tmp.persist(env_file_path) { eprintln!("[migration] Failed to persist migrated .env file: {e}"); return; } @@ -381,7 +375,7 @@ impl Config { { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); - if let Err(e) = std::fs::set_permissions(&env_file_path, perms) { + if let Err(e) = std::fs::set_permissions(env_file_path, perms) { eprintln!("[migration] Could not set config file permissions to 0600: {e}"); } } @@ -408,14 +402,6 @@ impl Config { } impl NetworkConfig { - /// Check if configuration is set - #[allow(dead_code)] // May be used for validation - pub fn is_valid(&self) -> bool { - !self.core_rpc_user.is_empty() - && !self.core_rpc_password.is_empty() - && self.core_rpc_port != 0 - } - /// List of DAPI addresses, if explicitly configured. /// Returns `Ok(None)` when absent or empty (dynamic discovery should be used). pub fn dapi_address_list(&self) -> Result, String> { @@ -462,40 +448,6 @@ mod tests { } } - // ── NetworkConfig::is_valid ────────────────────────────────────── - - #[test] - fn test_is_valid_with_all_fields() { - let config = make_network_config("https://127.0.0.1:443", 9998); - assert!(config.is_valid()); - } - - #[test] - fn test_is_valid_empty_user() { - let mut config = make_network_config("https://127.0.0.1:443", 9998); - config.core_rpc_user = String::new(); - assert!(!config.is_valid()); - } - - #[test] - fn test_is_valid_empty_password() { - let mut config = make_network_config("https://127.0.0.1:443", 9998); - config.core_rpc_password = String::new(); - assert!(!config.is_valid()); - } - - #[test] - fn test_is_valid_zero_port() { - let config = make_network_config("https://127.0.0.1:443", 0); - assert!(!config.is_valid()); - } - - #[test] - fn test_is_valid_no_dapi_addresses() { - let config = make_network_config("", 9998); - assert!(config.is_valid()); - } - // ── NetworkConfig::dapi_address_list ───────────────────────────── #[test] diff --git a/src/dapi_discovery.rs b/src/dapi_discovery.rs index 9c69da154..45f107a36 100644 --- a/src/dapi_discovery.rs +++ b/src/dapi_discovery.rs @@ -5,7 +5,9 @@ //! //! - **Mainnet**: `https://quorums.mainnet.networks.dash.org/masternodes` //! - **Testnet**: `https://quorums.testnet.networks.dash.org/masternodes` -//! - **Devnet**: `https://quorums.devnet..networks.dash.org/masternodes` +//! +//! Devnet and Regtest do **not** support dynamic discovery and require +//! explicit DAPI addresses in the `.env` configuration. //! //! ## Trust model //! From 112c873faf95036acc58d65846f318e66c793cce Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:40:59 +0200 Subject: [PATCH 15/48] =?UTF-8?q?revert(app):=20remove=20deferred=20networ?= =?UTF-8?q?k=20context=20=E2=80=94=20moved=20to=20PR=20#803?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app.rs | 81 ++++++------------------------------------------------ 1 file changed, 8 insertions(+), 73 deletions(-) diff --git a/src/app.rs b/src/app.rs index 5405b82c0..1e5aa53b6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -11,7 +11,6 @@ use crate::context::connection_status::{ConnectionStatus, OverallConnectionState use crate::database::Database; #[cfg(not(feature = "testing"))] use crate::logging::initialize_logger; -use crate::model::password_info::PasswordInfo; use crate::model::settings::Settings; use crate::spv::CoreBackendMode; use crate::ui::components::{BannerHandle, MessageBanner}; @@ -66,17 +65,6 @@ impl From> for TaskResult { } } -/// Parameters needed for lazy `AppContext` creation when the user switches -/// to a network whose context was deferred at startup. -struct LazyContextParams { - data_dir: PathBuf, - db: Arc, - password_info: Option, - subtasks: Arc, - connection_status: Arc, - egui_ctx: egui::Context, -} - pub struct AppState { pub main_screens: BTreeMap, pub selected_main_screen: RootScreenType, @@ -87,8 +75,6 @@ pub struct AppState { pub testnet_app_context: Option>, pub devnet_app_context: Option>, pub local_app_context: Option>, - /// Params kept for lazy AppContext creation when switching networks. - lazy_ctx_params: 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 @@ -255,8 +241,6 @@ 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( @@ -270,38 +254,11 @@ impl AppState { ) }; - // Always create mainnet eagerly (required, non-optional). let mainnet_app_context = make_context(Network::Mainnet) .ok_or("Failed to create AppContext for mainnet. Check your Dash configuration.")?; - - // Only create the saved/active network eagerly; defer others until the - // user switches to them. This avoids DAPI discovery + SDK init for - // networks the user may never use, eliminating potential startup delays. - let testnet_app_context = if saved_network == Network::Testnet { - make_context(Network::Testnet) - } else { - None - }; - let devnet_app_context = if saved_network == Network::Devnet { - make_context(Network::Devnet) - } else { - None - }; - let local_app_context = if saved_network == Network::Regtest { - make_context(Network::Regtest) - } else { - None - }; - - // Store params for lazy context creation when switching networks later. - let lazy_ctx_params = Some(LazyContextParams { - data_dir, - db: db.clone(), - password_info, - subtasks: subtasks.clone(), - connection_status: connection_status.clone(), - egui_ctx: ctx.clone(), - }); + let testnet_app_context = make_context(Network::Testnet); + let devnet_app_context = make_context(Network::Devnet); + let local_app_context = make_context(Network::Regtest); // load fonts ctx.set_fonts(crate::bundled::fonts().expect("failed to load fonts")); @@ -809,7 +766,6 @@ impl AppState { testnet_app_context, devnet_app_context, local_app_context, - lazy_ctx_params, mainnet_core_zmq_listener, testnet_core_zmq_listener, devnet_core_zmq_listener, @@ -999,33 +955,12 @@ impl AppState { } pub fn change_network(&mut self, network: Network) { - // Lazily create the AppContext if this network was deferred at startup. if !self.context_available_for_network(network) { - if let Some(ref params) = self.lazy_ctx_params { - let ctx = AppContext::new( - params.data_dir.clone(), - network, - params.db.clone(), - params.password_info.clone(), - params.subtasks.clone(), - params.connection_status.clone(), - params.egui_ctx.clone(), - ); - match network { - Network::Testnet => self.testnet_app_context = ctx, - Network::Devnet => self.devnet_app_context = ctx, - Network::Regtest => self.local_app_context = ctx, - _ => {} - } - } - - if !self.context_available_for_network(network) { - tracing::error!( - "Cannot switch to {:?}: network context not available. Staying on current network.", - network - ); - return; - } + tracing::error!( + "Cannot switch to {:?}: network context not available. Staying on current network.", + network + ); + return; } self.chosen_network = network; From 566481235da5292f162b334c98e8d4f827d8d930 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:49:42 +0200 Subject: [PATCH 16/48] refactor(app): move network switch context creation to background task Network context creation (SDK init, DAPI discovery) now runs on a background thread instead of blocking the UI. When switching to a deferred network, the UI shows a progress banner while the context is created asynchronously. The fast path (context already exists) remains synchronous. Adds BackendTask::SwitchNetwork for MCP discoverability (intercepted by AppState before dispatch), BackendTaskSuccessResult::NetworkContextCreated, and TaskError::NetworkContextCreationFailed for the async flow. Also spawns ZMQ listeners for lazily created network contexts and moves settings persistence into finalize_network_switch. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app.rs | 118 +++++++++++++++++++++++++++++--------- src/backend_task/error.rs | 8 +++ src/backend_task/mod.rs | 15 ++++- 3 files changed, 114 insertions(+), 27 deletions(-) diff --git a/src/app.rs b/src/app.rs index 5cbdbd6a6..361e0344c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -68,6 +68,7 @@ impl From> for TaskResult { /// Parameters needed for lazy `AppContext` creation when the user switches /// to a network whose context was deferred at startup. +#[derive(Clone)] struct LazyContextParams { data_dir: PathBuf, db: Arc, @@ -147,8 +148,12 @@ pub struct AppState { pub network_contexts: BTreeMap>, /// Params kept for lazy AppContext creation when switching networks. lazy_ctx_params: Option, + /// Network whose context is being created asynchronously. While `Some`, + /// the UI shows a progress banner and ignores further switch requests. + network_switch_pending: Option, #[allow(dead_code)] // Kept alive for the lifetime of the app 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 @@ -550,7 +555,9 @@ impl AppState { connection_status, network_contexts, lazy_ctx_params, + network_switch_pending: None, zmq_listeners, + core_message_sender, core_message_receiver, task_result_sender, task_result_receiver, @@ -637,7 +644,13 @@ 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) { + // Intercept SwitchNetwork — it creates a new context rather than using the current one. + if let BackendTask::SwitchNetwork(network) = task { + self.change_network(network); + return; + } + let sender = self.task_result_sender.clone(); let app_context = self.current_app_context().clone(); let handle = tokio::runtime::Handle::current(); @@ -726,31 +739,56 @@ impl AppState { } pub fn change_network(&mut self, network: Network) { - // Lazily create the AppContext if this network was deferred at startup. - if !self.context_available_for_network(network) { - if let Some(ref params) = self.lazy_ctx_params - && let Some(ctx) = AppContext::new( - params.data_dir.clone(), - network, - params.db.clone(), - params.password_info.clone(), - params.subtasks.clone(), - params.connection_status.clone(), - params.egui_ctx.clone(), - ) - { - self.network_contexts.insert(network, ctx); - } + // Ignore if we're already switching to this network. + if self.network_switch_pending == Some(network) { + return; + } - if !self.context_available_for_network(network) { - tracing::error!( - "Cannot switch to {:?}: network context not available. Staying on current network.", - network - ); - return; - } + // Fast path: context already exists — switch immediately. + if self.context_available_for_network(network) { + self.finalize_network_switch(network); + return; } + // Slow path: create the context on a background thread. + let Some(params) = self.lazy_ctx_params.clone() else { + tracing::error!( + ?network, + "Cannot switch network: lazy context params not available" + ); + return; + }; + + self.network_switch_pending = Some(network); + let sender = self.task_result_sender.clone(); + + tokio::task::spawn_blocking(move || { + let result = AppContext::new( + params.data_dir, + network, + params.db, + params.password_info, + params.subtasks, + params.connection_status, + params.egui_ctx, + ); + let task_result = match result { + Some(ctx) => { + TaskResult::Success(Box::new(BackendTaskSuccessResult::NetworkContextCreated { + network, + context: ctx, + })) + } + None => TaskResult::Error(TaskError::NetworkContextCreationFailed { network }), + }; + if let Err(e) = sender.try_send(task_result) { + tracing::error!("Failed to send network context creation result: {}", e); + } + }); + } + + /// 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(); @@ -780,6 +818,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 @@ -1062,6 +1113,11 @@ impl App for AppState { ); self.visible_screen_mut().refresh(); } + BackendTaskSuccessResult::NetworkContextCreated { network, context } => { + self.network_contexts.insert(network, context); + self.network_switch_pending = None; + self.finalize_network_switch(network); + } _ => { // For all other success results, let the screen decide how to display // the outcome without showing a generic global success banner. @@ -1076,6 +1132,10 @@ impl App for AppState { .display_message(&msg, MessageType::Success); self.visible_screen_mut().refresh(); } + TaskResult::Error(err @ TaskError::NetworkContextCreationFailed { .. }) => { + self.network_switch_pending = None; + MessageBanner::set_global(ctx, err.to_string(), MessageType::Error); + } TaskResult::Error(err) => { // Let the screen handle specific error types first. // If handled, skip the generic error banner. @@ -1103,6 +1163,15 @@ impl App for AppState { } } + // Show a progress banner while a network switch is in progress. + if let Some(pending_network) = self.network_switch_pending { + MessageBanner::set_global( + ctx, + format!("Connecting to {pending_network:?}..."), + MessageType::Info, + ); + } + // Schedule a periodic repaint every ~1 second so timed messages update // their countdown and other periodic UI elements stay current. // Throttled so we don't re-schedule on every frame during user interaction. @@ -1288,9 +1357,6 @@ impl App for AppState { } 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]; diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index 42df3946b..6e1db2170 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; @@ -910,6 +911,13 @@ pub enum TaskError { /// Nullifier sync failed. #[error("Could not check for spent shielded notes. Please check your connection and retry.")] ShieldedNullifierSyncFailed { detail: String }, + + // ────────────────────────────────────────────────────────────────────────── + // Network context errors + // ────────────────────────────────────────────────────────────────────────── + /// Creating a network context failed during a network switch. + #[error("Could not connect to {network:?}. Check your network configuration and retry.")] + NetworkContextCreationFailed { network: Network }, } /// Returns `true` when a `dashcore_rpc::Error` wraps an HTTP 401 response, diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index 0f47c8628..c5feea993 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -11,6 +11,7 @@ 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::Network; use dash_sdk::dpp::dashcore::address::NetworkChecked; use dash_sdk::dpp::dashcore::bls_sig_utils::BLSSignature; use dash_sdk::dpp::dashcore::network::message_qrinfo::QRInfo; @@ -97,10 +98,13 @@ pub enum BackendTask { GroveSTARKTask(GroveSTARKTask), WalletTask(WalletTask), ShieldedTask(ShieldedTask), + /// Create a new network context and switch to it. + /// Intercepted by `AppState` — never dispatched to `AppContext::run_backend_task`. + SwitchNetwork(Network), None, } -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone)] #[allow(clippy::large_enum_variant)] pub enum BackendTaskSuccessResult { // General results @@ -330,6 +334,12 @@ pub enum BackendTaskSuccessResult { amount: u64, }, ProvingKeyReady, + + /// A new network context was created asynchronously during a network switch. + NetworkContextCreated { + network: Network, + context: Arc, + }, } impl BackendTaskSuccessResult {} @@ -415,6 +425,9 @@ impl AppContext { BackendTask::ShieldedTask(shielded_task) => { Ok(self.run_shielded_task(shielded_task).await?) } + BackendTask::SwitchNetwork(_) => { + unreachable!("SwitchNetwork is intercepted by AppState::handle_backend_task") + } BackendTask::None => Ok(BackendTaskSuccessResult::None), } } From 825981c2799d417a1d4969fb77cdf2c9436e0f1d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:01:18 +0200 Subject: [PATCH 17/48] fix(ui): always show mainnet/testnet in network chooser, devnet/local in dev mode Network chooser combo box previously hid networks whose AppContext failed to initialize (e.g. missing RPC config for testnet in SPV mode). Now mainnet and testnet are always visible; devnet and local appear only in developer mode. Selecting a network without a context shows a user-friendly error banner instead of silently refusing. Also makes Core RPC fields (host, port, user, password) optional in NetworkConfig so that envy parsing succeeds with just DAPI addresses, enabling SPV-only network configs. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app.rs | 14 ++++ src/backend_task/core/mod.rs | 16 +++- src/config.rs | 126 ++++++++++++++++++------------- src/context/mod.rs | 33 ++++++-- src/context_provider.rs | 9 ++- src/spv/manager.rs | 2 +- src/spv/tests.rs | 8 +- src/ui/network_chooser_screen.rs | 24 +++--- 8 files changed, 147 insertions(+), 85 deletions(-) diff --git a/src/app.rs b/src/app.rs index 1e5aa53b6..0a8b71ba9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -956,10 +956,24 @@ 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 ); + MessageBanner::set_global( + self.mainnet_app_context.egui_ctx(), + format!( + "Could not connect to {network_name}. Check your network settings and retry." + ), + MessageType::Error, + ); return; } diff --git a/src/backend_task/core/mod.rs b/src/backend_task/core/mod.rs index 2136b401b..46fb5e812 100644 --- a/src/backend_task/core/mod.rs +++ b/src/backend_task/core/mod.rs @@ -442,7 +442,8 @@ impl AppContext { let addr = format!( "http://{}:{}", - network_config.core_host, network_config.core_rpc_port + network_config.core_host.as_deref().unwrap_or("127.0.0.1"), + network_config.core_rpc_port.unwrap_or(9998) ); let cookie_path = match core_cookie_path(network, &network_config.devnet_name) { @@ -464,8 +465,8 @@ impl AppContext { match Client::new( &addr, Auth::UserPass( - network_config.core_rpc_user.to_string(), - network_config.core_rpc_password.to_string(), + network_config.core_rpc_user.clone().unwrap_or_default(), + network_config.core_rpc_password.clone().unwrap_or_default(), ), ) { Ok(c) => c, @@ -492,7 +493,14 @@ impl AppContext { if is_rpc_connection_error(e) { let url = config .as_ref() - .map(|c| format!("{}:{} ({})", c.core_host, c.core_rpc_port, e)) + .map(|c| { + format!( + "{}:{} ({})", + c.core_host.as_deref().unwrap_or("127.0.0.1"), + c.core_rpc_port.unwrap_or(9998), + e + ) + }) .unwrap_or_else(|| "unknown".to_string()); return Some(TaskError::CoreRpcConnectionFailed { url, source: None }); } diff --git a/src/config.rs b/src/config.rs index 055cf44e8..85d4d6de1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -52,21 +52,22 @@ pub enum ConfigError { NoValidConfigs, } -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Default, Deserialize, Clone)] +#[serde(default)] pub struct NetworkConfig { /// Hostname of Dash Platform node to connect to. /// `None` or empty means dynamic discovery will be used. /// Dynamic discovery is currently supported for Mainnet and Testnet only. /// Devnet and Regtest require explicit addresses. pub dapi_addresses: Option, - /// Host of the Dash Core RPC interface - pub core_host: String, - /// Port of the Dash Core RPC interface - pub core_rpc_port: u16, - /// Username for Dash Core RPC interface - pub core_rpc_user: String, - /// Password for Dash Core RPC interface - pub core_rpc_password: String, + /// Host of the Dash Core RPC interface (only needed in RPC mode) + pub core_host: Option, + /// Port of the Dash Core RPC interface (only needed in RPC mode) + pub core_rpc_port: Option, + /// Username for Dash Core RPC interface (only needed in RPC mode) + pub core_rpc_user: Option, + /// Password for Dash Core RPC interface (only needed in RPC mode) + pub core_rpc_password: Option, /// ZMQ endpoint for Core blockchain events (e.g., tcp://127.0.0.1:23708) pub core_zmq_endpoint: Option, /// Devnet network name if one exists @@ -126,18 +127,22 @@ impl Config { writeln!(env_file, "{}dapi_addresses={}", prefix, addrs) .map_err(|e| ConfigError::SaveError { source: e })?; } - writeln!(env_file, "{}core_host={}", prefix, config.core_host) - .map_err(|e| ConfigError::SaveError { source: e })?; - writeln!(env_file, "{}core_rpc_port={}", prefix, config.core_rpc_port) - .map_err(|e| ConfigError::SaveError { source: e })?; - writeln!(env_file, "{}core_rpc_user={}", prefix, config.core_rpc_user) - .map_err(|e| ConfigError::SaveError { source: e })?; - writeln!( - env_file, - "{}core_rpc_password={}", - prefix, config.core_rpc_password - ) - .map_err(|e| ConfigError::SaveError { source: e })?; + if let Some(ref host) = config.core_host { + writeln!(env_file, "{}core_host={}", prefix, host) + .map_err(|e| ConfigError::SaveError { source: e })?; + } + if let Some(port) = config.core_rpc_port { + writeln!(env_file, "{}core_rpc_port={}", prefix, port) + .map_err(|e| ConfigError::SaveError { source: e })?; + } + if let Some(ref user) = config.core_rpc_user { + writeln!(env_file, "{}core_rpc_user={}", prefix, user) + .map_err(|e| ConfigError::SaveError { source: e })?; + } + if let Some(ref password) = config.core_rpc_password { + writeln!(env_file, "{}core_rpc_password={}", prefix, password) + .map_err(|e| ConfigError::SaveError { source: e })?; + } if let Some(core_zmq_endpoint) = &config.core_zmq_endpoint { writeln!( env_file, @@ -420,7 +425,7 @@ impl NetworkConfig { /// Update just the `core_rpc_password` in a builder-like manner. /// Returns a new `NetworkConfig` with the updated password. pub fn update_core_rpc_password(mut self, new_password: String) -> Self { - self.core_rpc_password = new_password; + self.core_rpc_password = Some(new_password); self } } @@ -438,10 +443,10 @@ mod tests { }; NetworkConfig { dapi_addresses: dapi, - core_host: "127.0.0.1".to_string(), - core_rpc_port: port, - core_rpc_user: "dashrpc".to_string(), - core_rpc_password: "password".to_string(), + core_host: Some("127.0.0.1".to_string()), + core_rpc_port: Some(port), + core_rpc_user: Some("dashrpc".to_string()), + core_rpc_password: Some("password".to_string()), core_zmq_endpoint: Some("tcp://127.0.0.1:23708".to_string()), devnet_name: None, wallet_private_key: None, @@ -477,12 +482,12 @@ mod tests { #[test] fn test_update_core_rpc_password() { let config = make_network_config("https://127.0.0.1:443", 9998); - assert_eq!(config.core_rpc_password, "password"); + assert_eq!(config.core_rpc_password.as_deref(), Some("password")); let updated = config.update_core_rpc_password("new_secret".to_string()); - assert_eq!(updated.core_rpc_password, "new_secret"); + assert_eq!(updated.core_rpc_password.as_deref(), Some("new_secret")); // Other fields should be unchanged - assert_eq!(updated.core_rpc_user, "dashrpc"); - assert_eq!(updated.core_rpc_port, 9998); + assert_eq!(updated.core_rpc_user.as_deref(), Some("dashrpc")); + assert_eq!(updated.core_rpc_port, Some(9998)); } // ── Config::config_for_network ────────────────────────────────── @@ -516,19 +521,19 @@ mod tests { .config_for_network(Network::Mainnet) .as_ref() .unwrap(); - assert_eq!(main.core_rpc_port, 9998); + assert_eq!(main.core_rpc_port, Some(9998)); let test = config .config_for_network(Network::Testnet) .as_ref() .unwrap(); - assert_eq!(test.core_rpc_port, 19998); + assert_eq!(test.core_rpc_port, Some(19998)); let dev = config.config_for_network(Network::Devnet).as_ref().unwrap(); - assert_eq!(dev.core_rpc_port, 29998); + assert_eq!(dev.core_rpc_port, Some(29998)); let local = config .config_for_network(Network::Regtest) .as_ref() .unwrap(); - assert_eq!(local.core_rpc_port, 20302); + assert_eq!(local.core_rpc_port, Some(20302)); } // ── Config::update_config_for_network ─────────────────────────── @@ -546,7 +551,10 @@ mod tests { let new_cfg = make_network_config("https://1.1.1.1:443", 9998); config.update_config_for_network(Network::Mainnet, new_cfg); assert!(config.mainnet_config.is_some()); - assert_eq!(config.mainnet_config.as_ref().unwrap().core_rpc_port, 9998); + assert_eq!( + config.mainnet_config.as_ref().unwrap().core_rpc_port, + Some(9998) + ); } #[test] @@ -561,7 +569,7 @@ mod tests { let new_cfg = make_network_config("https://new.example.com:443", 2222); config.update_config_for_network(Network::Mainnet, new_cfg); let main = config.mainnet_config.as_ref().unwrap(); - assert_eq!(main.core_rpc_port, 2222); + assert_eq!(main.core_rpc_port, Some(2222)); assert_eq!( main.dapi_addresses.as_deref(), Some("https://new.example.com:443") @@ -634,13 +642,18 @@ mod tests { if let Some(ref addrs) = cfg.dapi_addresses { output.push_str(&format!("MAINNET_dapi_addresses={}\n", addrs)); } - output.push_str(&format!("MAINNET_core_host={}\n", cfg.core_host)); - output.push_str(&format!("MAINNET_core_rpc_port={}\n", cfg.core_rpc_port)); - output.push_str(&format!("MAINNET_core_rpc_user={}\n", cfg.core_rpc_user)); - output.push_str(&format!( - "MAINNET_core_rpc_password={}\n", - cfg.core_rpc_password - )); + if let Some(ref host) = cfg.core_host { + output.push_str(&format!("MAINNET_core_host={}\n", host)); + } + if let Some(port) = cfg.core_rpc_port { + output.push_str(&format!("MAINNET_core_rpc_port={}\n", port)); + } + if let Some(ref user) = cfg.core_rpc_user { + output.push_str(&format!("MAINNET_core_rpc_user={}\n", user)); + } + if let Some(ref password) = cfg.core_rpc_password { + output.push_str(&format!("MAINNET_core_rpc_password={}\n", password)); + } if let Some(ref zmq) = cfg.core_zmq_endpoint { output.push_str(&format!("MAINNET_core_zmq_endpoint={}\n", zmq)); } @@ -684,10 +697,10 @@ mod tests { config.dapi_addresses.as_deref(), Some("https://1.2.3.4:443") ); - assert_eq!(config.core_host, "192.168.1.100"); - assert_eq!(config.core_rpc_port, 9998); - assert_eq!(config.core_rpc_user, "testuser"); - assert_eq!(config.core_rpc_password, "testpass"); + assert_eq!(config.core_host.as_deref(), Some("192.168.1.100")); + assert_eq!(config.core_rpc_port, Some(9998)); + assert_eq!(config.core_rpc_user.as_deref(), Some("testuser")); + assert_eq!(config.core_rpc_password.as_deref(), Some("testpass")); assert_eq!( config.core_zmq_endpoint, Some("tcp://127.0.0.1:29999".to_string()) @@ -719,20 +732,25 @@ mod tests { } #[test] - fn test_envy_parsing_missing_required_field_fails() { + fn test_envy_parsing_missing_rpc_fields_succeeds() { use std::collections::HashMap; - // Missing core_rpc_port (required) + // Only DAPI addresses -- RPC fields are optional (SPV mode) let mut env_map: HashMap = HashMap::new(); env_map.insert("MISS_dapi_addresses".into(), "https://1.2.3.4:443".into()); - env_map.insert("MISS_core_host".into(), "127.0.0.1".into()); - // core_rpc_port intentionally missing - env_map.insert("MISS_core_rpc_user".into(), "user".into()); - env_map.insert("MISS_core_rpc_password".into(), "pass".into()); let result: Result = envy::prefixed("MISS_").from_iter(env_map.iter().map(|(k, v)| (k.clone(), v.clone()))); - assert!(result.is_err()); + assert!(result.is_ok(), "Failed to parse: {:?}", result.err()); + let config = result.unwrap(); + assert_eq!( + config.dapi_addresses.as_deref(), + Some("https://1.2.3.4:443") + ); + assert!(config.core_host.is_none()); + assert!(config.core_rpc_port.is_none()); + assert!(config.core_rpc_user.is_none()); + assert!(config.core_rpc_password.is_none()); } #[test] diff --git a/src/context/mod.rs b/src/context/mod.rs index 8da109ae9..70755444c 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -229,7 +229,8 @@ impl AppContext { let addr = format!( "http://{}:{}", - network_config.core_host, network_config.core_rpc_port + network_config.core_host.as_deref().unwrap_or("127.0.0.1"), + network_config.core_rpc_port.unwrap_or(9998) ); let core_client = match Self::create_core_rpc_client( &addr, @@ -551,10 +552,17 @@ impl AppContext { // Note: developer_mode is now global and managed separately // 2. Rebuild the RPC client with the new password - let addr = format!("http://{}:{}", cfg.core_host, cfg.core_rpc_port); + let addr = format!( + "http://{}:{}", + cfg.core_host.as_deref().unwrap_or("127.0.0.1"), + cfg.core_rpc_port.unwrap_or(9998) + ); let new_client = Client::new( &addr, - Auth::UserPass(cfg.core_rpc_user.clone(), cfg.core_rpc_password.clone()), + Auth::UserPass( + cfg.core_rpc_user.clone().unwrap_or_default(), + cfg.core_rpc_password.clone().unwrap_or_default(), + ), ) .map_err(|e| TaskError::RpcProviderCreationFailed { detail: e.to_string(), @@ -628,7 +636,10 @@ impl AppContext { } Client::new( url, - Auth::UserPass(cfg.core_rpc_user.clone(), cfg.core_rpc_password.clone()), + Auth::UserPass( + cfg.core_rpc_user.clone().unwrap_or_default(), + cfg.core_rpc_password.clone().unwrap_or_default(), + ), ) .map_err(|e| TaskError::CoreRpc { source: e }) } @@ -639,7 +650,11 @@ impl AppContext { let cfg = self.config.read().map_err(|_| TaskError::LockPoisoned { resource: "NetworkConfig", })?; - let base = format!("http://{}:{}", cfg.core_host, cfg.core_rpc_port); + let base = format!( + "http://{}:{}", + cfg.core_host.as_deref().unwrap_or("127.0.0.1"), + cfg.core_rpc_port.unwrap_or(9998) + ); let url = match wallet_name { Some(name) if !name.is_empty() => { if name.contains("..") { @@ -696,7 +711,13 @@ impl AppContext { .config .read() .ok() - .map(|c| format!("{}:{}", c.core_host, c.core_rpc_port)) + .map(|c| { + format!( + "{}:{}", + c.core_host.as_deref().unwrap_or("127.0.0.1"), + c.core_rpc_port.unwrap_or(9998) + ) + }) .unwrap_or_else(|| "unknown".to_string()); TaskError::CoreRpcConnectionFailed { url, diff --git a/src/context_provider.rs b/src/context_provider.rs index d84cff60a..8d22bd555 100644 --- a/src/context_provider.rs +++ b/src/context_provider.rs @@ -96,13 +96,14 @@ impl Provider { } else { // Fall back to the pre-set user / pass if needed ( - config.core_rpc_user.clone(), - config.core_rpc_password.clone(), + config.core_rpc_user.clone().unwrap_or_default(), + config.core_rpc_password.clone().unwrap_or_default(), ) }; - let core_client = CoreClient::new(&config.core_host, config.core_rpc_port, &user, &pass) - .map_err(|e| e.to_string())?; + let host = config.core_host.as_deref().unwrap_or("127.0.0.1"); + let port = config.core_rpc_port.unwrap_or(9998); + let core_client = CoreClient::new(host, port, &user, &pass).map_err(|e| e.to_string())?; Ok(Self { db, diff --git a/src/spv/manager.rs b/src/spv/manager.rs index ae884fcff..e1142a487 100644 --- a/src/spv/manager.rs +++ b/src/spv/manager.rs @@ -1463,7 +1463,7 @@ impl SpvManager { fn primary_peer_socket(&self) -> Option { let config = self.config.read().ok()?; - let host = config.core_host.as_str(); + let host = config.core_host.as_deref().unwrap_or("127.0.0.1"); let port = match self.network { Network::Mainnet => 9999, Network::Testnet => 19999, diff --git a/src/spv/tests.rs b/src/spv/tests.rs index 3f43f16ad..7b75baed9 100644 --- a/src/spv/tests.rs +++ b/src/spv/tests.rs @@ -16,10 +16,10 @@ const DEADLOCK_TIMEOUT: Duration = Duration::from_secs(10); fn test_network_config() -> NetworkConfig { NetworkConfig { dapi_addresses: Some("https://127.0.0.1:1443".to_string()), - core_host: "127.0.0.1".to_string(), - core_rpc_port: 19998, - core_rpc_user: "dashrpc".to_string(), - core_rpc_password: "password".to_string(), + core_host: Some("127.0.0.1".to_string()), + core_rpc_port: Some(19998), + core_rpc_user: Some("dashrpc".to_string()), + core_rpc_password: Some("password".to_string()), core_zmq_endpoint: Some("tcp://127.0.0.1:23709".to_string()), devnet_name: None, wallet_private_key: None, diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index 175fcc639..0bdf6d078 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -128,7 +128,8 @@ impl NetworkChooserScreen { if let Ok(config) = Config::load_from(&mainnet_app_context.data_dir) && let Some(network_config) = config.config_for_network(current_network) { - dashmate_password_input.set_text(network_config.core_rpc_password.clone()); + dashmate_password_input + .set_text(network_config.core_rpc_password.clone().unwrap_or_default()); } let current_context = match current_network { @@ -382,18 +383,17 @@ impl NetworkChooserScreen { { app_action = AppAction::SwitchNetwork(Network::Mainnet); } - if self.testnet_app_context.is_some() - && ui - .selectable_value( - &mut self.current_network, - Network::Testnet, - "Testnet", - ) - .clicked() + if ui + .selectable_value( + &mut self.current_network, + Network::Testnet, + "Testnet", + ) + .clicked() { app_action = AppAction::SwitchNetwork(Network::Testnet); } - if self.devnet_app_context.is_some() + if self.developer_mode && ui .selectable_value( &mut self.current_network, @@ -404,7 +404,7 @@ impl NetworkChooserScreen { { app_action = AppAction::SwitchNetwork(Network::Devnet); } - if self.local_app_context.is_some() + if self.developer_mode && ui .selectable_value( &mut self.current_network, @@ -423,7 +423,7 @@ impl NetworkChooserScreen { .and_then(|c| { c.config_for_network(self.current_network) .as_ref() - .map(|nc| nc.core_rpc_password.clone()) + .and_then(|nc| nc.core_rpc_password.clone()) }) .unwrap_or_default(); self.dashmate_password_input.set_text(password); From 46e094e6c8bc4081f412acea6dc0e76b2de90037 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:12:59 +0200 Subject: [PATCH 18/48] fix(review): address PR #767 review comments in #803 - Thread #12: Route reinit_core_client_and_sdk through BackendTask system instead of blocking UI thread during RPC password save - Thread #18: Add runtime flavor guard before block_in_place in DAPI discovery to prevent panic on current-thread runtime - Thread #8: Allow Devnet dynamic discovery when devnet_name is configured; Devnet has no seed node fallback so discovery failure is final Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend_task/mod.rs | 11 +++ src/dapi_discovery.rs | 36 +++++--- src/ui/network_chooser_screen.rs | 139 ++++++++++++++++++------------- 3 files changed, 116 insertions(+), 70 deletions(-) diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index c5feea993..c433aa38d 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -98,6 +98,10 @@ pub enum BackendTask { GroveSTARKTask(GroveSTARKTask), WalletTask(WalletTask), ShieldedTask(ShieldedTask), + /// Rebuild the Core RPC client and SDK on the current network context. + /// Dispatched when the user saves a new RPC password so the reinit + /// (which includes DAPI discovery) runs off the UI thread. + ReinitCoreClientAndSdk, /// Create a new network context and switch to it. /// Intercepted by `AppState` — never dispatched to `AppContext::run_backend_task`. SwitchNetwork(Network), @@ -335,6 +339,9 @@ pub enum BackendTaskSuccessResult { }, 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, @@ -425,6 +432,10 @@ 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(_) => { unreachable!("SwitchNetwork is intercepted by AppState::handle_backend_task") } diff --git a/src/dapi_discovery.rs b/src/dapi_discovery.rs index 45f107a36..63a622ef5 100644 --- a/src/dapi_discovery.rs +++ b/src/dapi_discovery.rs @@ -6,8 +6,9 @@ //! - **Mainnet**: `https://quorums.mainnet.networks.dash.org/masternodes` //! - **Testnet**: `https://quorums.testnet.networks.dash.org/masternodes` //! -//! Devnet and Regtest do **not** support dynamic discovery and require -//! explicit DAPI addresses in the `.env` configuration. +//! Devnet supports dynamic discovery when a `devnet_name` is configured. +//! Regtest does **not** support dynamic discovery and requires explicit +//! DAPI addresses in the `.env` configuration. //! //! ## Trust model //! @@ -101,11 +102,13 @@ const TESTNET_SEED_NODES: &[&str] = &[ /// Resolve DAPI addresses synchronously. /// -/// If explicit addresses are configured, parses them directly. For mainnet and -/// testnet without explicit config, attempts dynamic discovery via -/// `tokio::task::block_in_place` (requires a tokio runtime). When discovery -/// fails for mainnet or testnet, falls back to hardcoded seed nodes so the -/// app can still start. Returns an error only if all resolution methods fail. +/// If explicit addresses are configured, parses them directly. For mainnet, +/// testnet, and devnet (when `devnet_name` is set) without explicit config, +/// attempts dynamic discovery via `tokio::task::block_in_place` (requires a +/// multi-thread tokio runtime). When discovery fails for mainnet or testnet, +/// falls back to hardcoded seed nodes so the app can still start. Devnet has +/// no seed-node fallback, so discovery failure is final. Returns an error +/// only if all resolution methods fail. pub fn resolve_dapi_addresses_sync( network: Network, dapi_addresses: &Option, @@ -121,19 +124,32 @@ pub fn resolve_dapi_addresses_sync( match network { Network::Mainnet | Network::Testnet => {} + Network::Devnet if devnet_name.is_some() => {} _ => return Err(DapiDiscoveryError::AddressesRequired { network }), } let discovery_result = match tokio::runtime::Handle::try_current() { - Ok(handle) => tokio::task::block_in_place(|| { - handle.block_on(try_discover_nodes(network, devnet_name)) - }), + Ok(handle) => { + // block_in_place requires a multi-thread runtime; on current-thread + // it would panic. Guard defensively for future callers. + if handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::CurrentThread { + Err(DapiDiscoveryError::NoRuntime) + } else { + tokio::task::block_in_place(|| { + handle.block_on(try_discover_nodes(network, devnet_name)) + }) + } + } Err(_) => Err(DapiDiscoveryError::NoRuntime), }; match discovery_result { Ok(list) => Ok(list), Err(e) => { + // Devnet has no hardcoded seed nodes -- discovery failure is final. + if network == Network::Devnet { + return Err(e); + } tracing::warn!( ?network, error = %e, diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index fb2cf062c..d6ae211d7 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -1,5 +1,6 @@ use crate::app::AppAction; use crate::backend_task::BackendTask; +use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::core::CoreTask; use crate::backend_task::system_task::SystemTask; use crate::config::Config; @@ -7,7 +8,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 +15,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; @@ -111,6 +112,11 @@ 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, } impl NetworkChooserScreen { @@ -196,6 +202,8 @@ impl NetworkChooserScreen { use_local_spv_node, auto_start_spv, close_dash_qt_on_exit, + config_save_failed: false, + reinit_banner: None, } } @@ -484,67 +492,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 reinit_failed = - if let Some(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 - } - } 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, - ); + let mut cfg_lock = app_context.config.write().unwrap(); + *cfg_lock = updated_config; } + + MessageBanner::clear_all_global(ui.ctx()); + 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 { + MessageBanner::set_global( + ui.ctx(), + "Core RPC password saved successfully.", + MessageType::Success, + ); } } }); @@ -2101,4 +2085,39 @@ impl ScreenLike for NetworkChooserScreen { action } + + fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { + if !matches!( + backend_task_success_result, + BackendTaskSuccessResult::CoreClientReinitialized + ) { + return; + } + + self.reinit_banner.take_and_clear(); + let save_failed = std::mem::take(&mut self.config_save_failed); + + // Reinit succeeded -- show the appropriate message based on + // whether the config file was saved successfully. AppState + // does not show a generic success banner for this variant, so + // we set it ourselves. + 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_message(&mut self, _msg: &str, _msg_type: MessageType) { + self.reinit_banner.take_and_clear(); + self.config_save_failed = false; + } } From 6f3c0279f6a9229740354df4c71e0b8f71f9d426 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:27:26 +0200 Subject: [PATCH 19/48] fix(config): update testnet seed nodes to 68.67.122.1-29 Replace old AWS-hosted testnet DAPI nodes with the new dedicated testnet infrastructure at 68.67.122.1-29:1443 (29 nodes). Co-Authored-By: Claude Opus 4.6 --- src/dapi_discovery.rs | 37 ++++++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/src/dapi_discovery.rs b/src/dapi_discovery.rs index 45f107a36..fb56a292a 100644 --- a/src/dapi_discovery.rs +++ b/src/dapi_discovery.rs @@ -89,14 +89,37 @@ const MAINNET_SEED_NODES: &[&str] = &[ "https://135.181.110.216:443", ]; -/// Hardcoded fallback seed nodes for testnet. +/// Hardcoded fallback seed nodes for testnet (68.67.122.1–29, port 1443). const TESTNET_SEED_NODES: &[&str] = &[ - "https://34.214.48.68:1443", - "https://52.12.176.90:1443", - "https://52.34.144.50:1443", - "https://44.240.98.102:1443", - "https://54.201.32.131:1443", - "https://52.10.229.11:1443", + "https://68.67.122.1:1443", + "https://68.67.122.2:1443", + "https://68.67.122.3:1443", + "https://68.67.122.4:1443", + "https://68.67.122.5:1443", + "https://68.67.122.6:1443", + "https://68.67.122.7:1443", + "https://68.67.122.8:1443", + "https://68.67.122.9:1443", + "https://68.67.122.10:1443", + "https://68.67.122.11:1443", + "https://68.67.122.12:1443", + "https://68.67.122.13:1443", + "https://68.67.122.14:1443", + "https://68.67.122.15:1443", + "https://68.67.122.16:1443", + "https://68.67.122.17:1443", + "https://68.67.122.18:1443", + "https://68.67.122.19:1443", + "https://68.67.122.20:1443", + "https://68.67.122.21:1443", + "https://68.67.122.22:1443", + "https://68.67.122.23:1443", + "https://68.67.122.24:1443", + "https://68.67.122.25:1443", + "https://68.67.122.26:1443", + "https://68.67.122.27:1443", + "https://68.67.122.28:1443", + "https://68.67.122.29:1443", ]; /// Resolve DAPI addresses synchronously. From dd5fe9f48c3c427c16493786b13cc4209844de0d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:40:53 +0200 Subject: [PATCH 20/48] fix(config): use network-aware default RPC ports and remove error leakage from URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace all hardcoded unwrap_or(9998) with NetworkConfig::rpc_port(network) which returns the correct default per network (mainnet: 9998, testnet: 19998, devnet: 29998, regtest: 19898). Adds rpc_port() and rpc_host() helper methods. Also removes raw transport error interpolation from chain_lock_rpc_error URL payload — the url field now contains only host:port without backend error text. Co-Authored-By: Claude Opus 4.6 --- src/backend_task/core/mod.rs | 18 +++++++----------- src/config.rs | 30 ++++++++++++++++++++++++++++++ src/context/mod.rs | 24 +++++------------------- src/context_provider.rs | 4 ++-- 4 files changed, 44 insertions(+), 32 deletions(-) diff --git a/src/backend_task/core/mod.rs b/src/backend_task/core/mod.rs index 46fb5e812..894faa7c0 100644 --- a/src/backend_task/core/mod.rs +++ b/src/backend_task/core/mod.rs @@ -194,7 +194,9 @@ impl AppContext { _ => (&mainnet_result, maybe_mainnet_config), }; let active_rpc_error = if let Err(e) = active_result { - if let Some(task_err) = Self::chain_lock_rpc_error(active_config, e) { + if let Some(task_err) = + Self::chain_lock_rpc_error(active_config, self.network, e) + { return Err(task_err); } // Non-auth, non-connection error — show the actual error @@ -442,8 +444,8 @@ impl AppContext { let addr = format!( "http://{}:{}", - network_config.core_host.as_deref().unwrap_or("127.0.0.1"), - network_config.core_rpc_port.unwrap_or(9998) + network_config.rpc_host(), + network_config.rpc_port(network) ); let cookie_path = match core_cookie_path(network, &network_config.devnet_name) { @@ -485,6 +487,7 @@ impl AppContext { /// `TaskError`, enriching connection failures with host:port. fn chain_lock_rpc_error( config: &Option, + network: Network, e: &dashcore_rpc::Error, ) -> Option { if is_rpc_auth_error(e) { @@ -493,14 +496,7 @@ impl AppContext { if is_rpc_connection_error(e) { let url = config .as_ref() - .map(|c| { - format!( - "{}:{} ({})", - c.core_host.as_deref().unwrap_or("127.0.0.1"), - c.core_rpc_port.unwrap_or(9998), - e - ) - }) + .map(|c| format!("{}:{}", c.rpc_host(), c.rpc_port(network))) .unwrap_or_else(|| "unknown".to_string()); return Some(TaskError::CoreRpcConnectionFailed { url, source: None }); } diff --git a/src/config.rs b/src/config.rs index 85d4d6de1..bfb74ff6c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -76,6 +76,36 @@ pub struct NetworkConfig { pub wallet_private_key: Option, } +impl NetworkConfig { + /// Default Core RPC port for the given network. + /// + /// Returns the well-known port when `core_rpc_port` is not explicitly set: + /// - Mainnet: 9998 + /// - Testnet: 19998 + /// - Devnet: 29998 + /// - Regtest: 19898 + pub fn default_rpc_port(network: Network) -> u16 { + match network { + Network::Mainnet => 9998, + Network::Testnet => 19998, + Network::Devnet => 29998, + Network::Regtest => 19898, + _ => 9998, + } + } + + /// Resolved Core RPC port — explicit config or network-aware default. + pub fn rpc_port(&self, network: Network) -> u16 { + self.core_rpc_port + .unwrap_or_else(|| Self::default_rpc_port(network)) + } + + /// Resolved Core RPC host — explicit config or localhost. + pub fn rpc_host(&self) -> &str { + self.core_host.as_deref().unwrap_or("127.0.0.1") + } +} + impl Config { pub fn config_for_network(&self, network: Network) -> &Option { match network { diff --git a/src/context/mod.rs b/src/context/mod.rs index 70755444c..a75cb1f4a 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -229,8 +229,8 @@ impl AppContext { let addr = format!( "http://{}:{}", - network_config.core_host.as_deref().unwrap_or("127.0.0.1"), - network_config.core_rpc_port.unwrap_or(9998) + network_config.rpc_host(), + network_config.rpc_port(network) ); let core_client = match Self::create_core_rpc_client( &addr, @@ -552,11 +552,7 @@ impl AppContext { // Note: developer_mode is now global and managed separately // 2. Rebuild the RPC client with the new password - let addr = format!( - "http://{}:{}", - cfg.core_host.as_deref().unwrap_or("127.0.0.1"), - cfg.core_rpc_port.unwrap_or(9998) - ); + let addr = format!("http://{}:{}", cfg.rpc_host(), cfg.rpc_port(self.network)); let new_client = Client::new( &addr, Auth::UserPass( @@ -650,11 +646,7 @@ impl AppContext { let cfg = self.config.read().map_err(|_| TaskError::LockPoisoned { resource: "NetworkConfig", })?; - let base = format!( - "http://{}:{}", - cfg.core_host.as_deref().unwrap_or("127.0.0.1"), - cfg.core_rpc_port.unwrap_or(9998) - ); + let base = format!("http://{}:{}", cfg.rpc_host(), cfg.rpc_port(self.network)); let url = match wallet_name { Some(name) if !name.is_empty() => { if name.contains("..") { @@ -711,13 +703,7 @@ impl AppContext { .config .read() .ok() - .map(|c| { - format!( - "{}:{}", - c.core_host.as_deref().unwrap_or("127.0.0.1"), - c.core_rpc_port.unwrap_or(9998) - ) - }) + .map(|c| format!("{}:{}", c.rpc_host(), c.rpc_port(self.network))) .unwrap_or_else(|| "unknown".to_string()); TaskError::CoreRpcConnectionFailed { url, diff --git a/src/context_provider.rs b/src/context_provider.rs index 8d22bd555..7cfda9dfa 100644 --- a/src/context_provider.rs +++ b/src/context_provider.rs @@ -101,8 +101,8 @@ impl Provider { ) }; - let host = config.core_host.as_deref().unwrap_or("127.0.0.1"); - let port = config.core_rpc_port.unwrap_or(9998); + let host = config.rpc_host(); + let port = config.rpc_port(network); let core_client = CoreClient::new(host, port, &user, &pass).map_err(|e| e.to_string())?; Ok(Self { From 5b99de35d9c750d23126074a3f91897c04b898f8 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 17:02:48 +0200 Subject: [PATCH 21/48] refactor(dapi): replace auto-discovery with manual fetch button Remove automatic DAPI node discovery at startup and .env migration. The app now always boots from configured addresses. Add "Fetch Node List" button to Network Settings (Mainnet/Testnet only) that fetches fresh addresses from DCG's discovery service on demand. Shows health ratio (available/total reachable) alongside the button. Fetched addresses are saved to config and SDK is reinitialized. Co-Authored-By: Claude Opus 4.6 --- src/app.rs | 4 +- src/backend_task/mod.rs | 27 +++++ src/config.rs | 109 ------------------- src/context/mod.rs | 50 ++++++--- src/dapi_discovery.rs | 148 +++----------------------- src/ui/network_chooser_screen.rs | 177 ++++++++++++++++++++++++++++++- 6 files changed, 253 insertions(+), 262 deletions(-) diff --git a/src/app.rs b/src/app.rs index 0a8b71ba9..028297692 100644 --- a/src/app.rs +++ b/src/app.rs @@ -202,7 +202,7 @@ impl AppState { let data_dir = app_user_data_dir_path()?; ensure_data_dir_exists(&data_dir)?; ensure_env_file(&data_dir); - crate::config::Config::migrate_env_file_if_needed(&data_dir.join(".env")); + initialize_logger(); let db_file_path = data_file_path(&data_dir, "data.db")?; let db = Arc::new(Database::new(&db_file_path)?); @@ -219,7 +219,7 @@ impl AppState { let data_dir = app_user_data_dir_path()?; ensure_data_dir_exists(&data_dir)?; ensure_env_file(&data_dir); - crate::config::Config::migrate_env_file_if_needed(&data_dir.join(".env")); + let db = Arc::new( crate::database::test_helpers::create_test_database() .map_err(|e| format!("Failed to create test database: {}", e))?, diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index 0f47c8628..efc4c4262 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -12,6 +12,7 @@ use crate::backend_task::wallet::WalletTask; use crate::context::AppContext; use dash_sdk::dpp::dashcore::Address; use dash_sdk::dpp::dashcore::address::NetworkChecked; +use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::dashcore::bls_sig_utils::BLSSignature; use dash_sdk::dpp::dashcore::network::message_qrinfo::QRInfo; use dash_sdk::dpp::dashcore::BlockHash; @@ -97,6 +98,7 @@ pub enum BackendTask { GroveSTARKTask(GroveSTARKTask), WalletTask(WalletTask), ShieldedTask(ShieldedTask), + DiscoverDapiNodes { network: Network }, None, } @@ -330,6 +332,11 @@ pub enum BackendTaskSuccessResult { amount: u64, }, ProvingKeyReady, + DapiNodesDiscovered { + network: Network, + count: usize, + addresses_csv: String, + }, } impl BackendTaskSuccessResult {} @@ -415,6 +422,26 @@ impl AppContext { BackendTask::ShieldedTask(shielded_task) => { Ok(self.run_shielded_task(shielded_task).await?) } + BackendTask::DiscoverDapiNodes { network } => { + let devnet_name = { + self.config + .read() + .map_err(|_| TaskError::LockPoisoned { + resource: "NetworkConfig", + })? + .devnet_name + .clone() + }; + let addr_strings = + crate::dapi_discovery::try_discover_nodes(network, devnet_name.as_deref()) + .await + .map_err(TaskError::from)?; + Ok(BackendTaskSuccessResult::DapiNodesDiscovered { + network, + count: addr_strings.len(), + addresses_csv: addr_strings.join(","), + }) + } BackendTask::None => Ok(BackendTaskSuccessResult::None), } } diff --git a/src/config.rs b/src/config.rs index bfb74ff6c..25cdaf138 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,20 +8,6 @@ use dash_sdk::dpp::dashcore::Network; use serde::Deserialize; use tempfile::NamedTempFile; -/// Known old hardcoded mainnet DAPI address lists that should be migrated -/// to dynamic discovery. These are exact-match values from previous .env.example -/// files (pre-update and post-update from mnowatch.org scrape). -const KNOWN_OLD_MAINNET_ADDRESSES: &[&str] = &[ - // Original .env.example mainnet addresses (pre-mnowatch update) - "https://104.200.24.196:443,https://134.255.182.185:443,https://134.255.182.186:443,https://134.255.182.187:443,https://134.255.183.247:443,https://134.255.183.248:443,https://134.255.183.250:443,https://135.181.110.216:443,https://146.59.4.9:443,https://147.135.199.138:443,https://149.28.241.190:443,https://149.28.247.165:443,https://157.10.199.125:443,https://157.10.199.77:443,https://157.10.199.79:443,https://157.10.199.82:443,https://157.66.81.130:443,https://157.66.81.162:443,https://157.66.81.218:443,https://157.90.238.161:443,https://159.69.204.162:443,https://167.179.90.255:443,https://167.88.169.16:443,https://168.119.102.10:443,https://172.104.90.249:443,https://173.212.239.124:443,https://173.249.53.139:443,https://178.157.91.184:443,https://185.158.107.124:443,https://185.192.96.70:443,https://185.194.216.84:443,https://185.197.250.227:443,https://185.198.234.17:443,https://185.215.166.126:443,https://188.208.196.183:443,https://188.245.90.255:443,https://192.248.178.237:443,https://193.203.15.209:443,https://194.146.13.7:443,https://194.195.87.34:443,https://198.7.115.43:443,https://207.244.247.40:443,https://213.199.34.248:443,https://213.199.34.250:443,https://213.199.34.251:443,https://213.199.35.15:443,https://213.199.35.18:443,https://213.199.35.6:443,https://213.199.44.112:443,https://2.58.82.231:443,https://31.220.84.93:443,https://31.220.85.180:443,https://31.220.88.116:443,https://37.27.83.17:443,https://37.60.236.151:443,https://37.60.236.161:443,https://37.60.236.201:443,https://37.60.236.212:443,https://37.60.236.247:443,https://37.60.236.249:443,https://37.60.243.119:443,https://37.60.243.59:443,https://37.60.244.220:443,https://44.240.99.214:443,https://49.12.102.105:443,https://49.13.154.121:443,https://49.13.193.251:443,https://49.13.237.193:443,https://49.13.28.255:443,https://51.195.118.43:443,https://51.83.191.208:443,https://5.189.186.78:443,https://52.10.213.198:443,https://52.33.9.172:443,https://54.69.95.118:443,https://5.75.133.148:443,https://64.23.134.67:443,https://65.108.246.145:443,https://65.109.65.126:443,https://65.21.145.147:443,https://79.137.71.84:443,https://81.17.101.141:443,https://91.107.204.136:443,https://91.107.226.241:443,https://93.190.140.101:443,https://93.190.140.111:443,https://93.190.140.112:443,https://93.190.140.114:443,https://93.190.140.162:443,https://95.216.146.18:443", - // mnowatch.org-scraped mainnet addresses (279 nodes, 2026-03-17) - "https://147.45.183.128:443,https://216.238.75.46:443,https://89.125.209.110:443,https://84.247.180.201:443,https://134.255.182.186:443,https://93.115.172.39:443,https://5.189.164.253:443,https://38.242.197.189:443,https://83.222.9.253:443,https://95.111.241.20:443,https://66.245.196.52:443,https://173.212.245.118:443,https://202.71.14.79:443,https://185.252.234.238:443,https://178.215.237.134:443,https://207.180.233.6:443,https://89.125.209.69:443,https://136.244.99.17:443,https://173.212.232.90:443,https://92.255.76.59:443,https://37.60.244.253:443,https://157.90.238.161:443,https://85.92.111.111:443,https://213.199.54.171:443,https://84.247.180.198:443,https://82.211.21.252:443,https://161.97.75.36:443,https://23.88.63.58:443,https://207.244.247.40:443,https://45.32.70.131:443,https://31.220.91.43:443,https://52.33.9.172:443,https://161.97.88.199:443,https://109.199.124.30:443,https://185.198.234.17:443,https://139.84.236.208:443,https://38.242.218.26:443,https://207.180.224.96:443,https://62.171.170.222:443,https://75.119.138.9:443,https://75.119.153.10:443,https://31.220.91.60:443,https://82.211.21.251:443,https://95.179.159.65:443,https://44.240.99.214:443,https://5.75.133.148:443,https://213.199.54.37:443,https://161.97.88.219:443,https://89.35.131.149:443,https://192.248.178.237:443,https://161.97.153.122:443,https://37.60.235.218:443,https://207.180.241.242:443,https://195.26.254.228:443,https://45.77.11.194:443,https://139.84.232.129:443,https://161.97.175.233:443,https://147.45.103.99:443,https://89.125.209.195:443,https://91.198.108.35:443,https://85.193.90.107:443,https://161.97.180.105:443,https://213.199.54.34:443,https://65.108.246.145:443,https://64.176.10.71:443,https://158.247.247.241:443,https://37.60.254.213:443,https://149.102.140.101:443,https://139.180.143.115:443,https://37.60.235.205:443,https://213.199.44.112:443,https://213.199.53.161:443,https://178.253.42.64:443,https://162.212.35.100:443,https://185.239.209.6:443,https://62.171.138.186:443,https://173.212.196.214:443,https://37.60.254.202:443,https://134.255.182.185:443,https://139.84.137.143:443,https://144.91.87.82:443,https://161.97.91.217:443,https://82.211.21.249:443,https://194.163.159.171:443,https://80.240.19.200:443,https://144.126.141.62:443,https://173.249.21.12:443,https://161.97.159.172:443,https://194.163.156.190:443,https://139.84.170.10:443,https://164.68.118.37:443,https://143.198.145.184:443,https://84.247.180.200:443,https://37.60.234.205:443,https://43.133.171.101:443,https://192.248.175.198:443,https://81.200.152.144:443,https://161.97.142.210:443,https://43.167.244.109:443,https://92.53.120.89:443,https://172.236.244.81:443,https://62.171.144.192:443,https://146.59.153.204:443,https://84.247.180.190:443,https://185.215.164.84:443,https://172.238.7.25:443,https://157.173.122.20:443,https://45.153.70.126:443,https://185.141.216.4:443,https://82.208.20.153:443,https://95.111.239.54:443,https://85.190.243.3:443,https://51.195.235.166:443,https://156.67.29.45:443,https://82.211.21.38:443,https://93.115.172.37:443,https://89.35.131.39:443,https://93.115.172.38:443,https://161.97.104.37:443,https://75.119.128.71:443,https://161.97.117.125:443,https://49.13.28.255:443,https://52.36.102.91:443,https://139.99.201.103:443,https://109.199.120.79:443,https://161.97.74.173:443,https://168.119.102.10:443,https://45.135.180.79:443,https://49.13.237.193:443,https://45.135.180.130:443,https://173.212.251.130:443,https://38.242.206.103:443,https://37.60.254.201:443,https://75.119.156.254:443,https://37.27.83.17:443,https://38.242.145.178:443,https://45.135.180.114:443,https://66.29.147.83:443,https://161.97.83.102:443,https://37.60.236.230:443,https://89.35.131.23:443,https://89.125.50.206:443,https://37.60.244.35:443,https://38.242.206.56:443,https://194.163.166.76:443,https://70.34.206.123:443,https://51.158.169.237:443,https://213.199.54.35:443,https://108.61.165.170:443,https://89.35.131.219:443,https://161.97.163.75:443,https://185.166.217.154:443,https://157.173.122.21:443,https://37.60.236.41:443,https://38.242.200.227:443,https://193.168.3.82:443,https://149.28.223.171:443,https://217.76.54.175:443,https://167.86.93.21:443,https://109.123.244.131:443,https://155.133.26.122:443,https://51.195.47.118:443,https://45.135.180.70:443,https://167.88.169.16:443,https://216.238.99.9:443,https://37.60.244.87:443,https://91.222.237.98:443,https://157.173.122.26:443,https://82.211.21.18:443,https://52.10.213.198:443,https://139.84.231.221:443,https://45.94.58.58:443,https://178.18.254.136:443,https://84.247.180.205:443,https://161.97.106.14:443,https://49.13.193.251:443,https://78.141.225.100:443,https://77.232.129.86:443,https://91.198.108.37:443,https://167.86.94.138:443,https://89.23.112.100:443,https://95.179.241.182:443,https://92.63.176.202:443,https://188.225.39.14:443,https://176.57.213.170:443,https://93.115.172.36:443,https://82.211.21.16:443,https://37.60.235.224:443,https://95.216.146.18:443,https://167.114.153.110:443,https://37.60.235.89:443,https://95.111.233.139:443,https://109.199.97.144:443,https://37.60.235.24:443,https://31.220.84.93:443,https://161.97.180.182:443,https://161.97.91.68:443,https://89.125.209.120:443,https://89.125.50.14:443,https://161.97.179.214:443,https://45.85.147.192:443,https://37.60.254.205:443,https://109.199.124.135:443,https://158.220.101.10:443,https://87.228.24.64:443,https://5.189.151.7:443,https://158.247.208.247:443,https://37.60.254.211:443,https://64.176.35.235:443,https://46.62.250.247:443,https://185.215.164.186:443,https://161.97.102.156:443,https://89.125.209.106:443,https://213.171.12.108:443,https://91.198.108.36:443,https://49.12.102.105:443,https://37.60.246.228:443,https://89.125.209.178:443,https://147.45.236.121:443,https://72.62.58.108:443,https://62.171.136.245:443,https://54.69.95.118:443,https://31.220.77.84:443,https://185.119.57.243:443,https://161.97.96.120:443,https://89.125.209.234:443,https://195.201.238.55:443,https://135.181.110.216:443,https://207.180.213.141:443,https://89.223.120.216:443,https://45.76.141.74:443,https://161.97.66.31:443,https://161.97.85.182:443,https://84.247.187.76:443,https://188.245.90.255:443,https://82.211.21.250:443,https://144.91.106.188:443,https://167.235.102.194:443,https://164.68.114.36:443,https://43.167.239.145:443,https://207.180.233.246:443,https://167.86.77.58:443,https://185.239.208.110:443,https://75.119.137.66:443,https://157.173.122.25:443,https://82.211.21.40:443,https://89.125.209.152:443,https://62.171.170.14:443,https://45.77.129.235:443,https://185.209.230.13:443,https://75.119.152.219:443,https://37.60.234.168:443,https://89.23.117.144:443,https://37.60.243.59:443,https://89.35.131.218:443,https://193.168.3.112:443,https://5.189.145.80:443,https://178.18.244.255:443,https://37.60.235.39:443,https://77.221.148.204:443,https://89.125.209.27:443,https://91.198.108.38:443,https://207.180.218.245:443,https://77.42.74.68:443,https://158.220.101.28:443,https://89.35.131.158:443,https://161.97.106.164:443,https://185.202.236.58:443,https://173.199.71.83:443,https://185.215.166.126:443,https://57.131.28.197:443,https://217.25.89.156:443,https://72.60.38.160:443,https://82.97.240.124:443,https://89.125.209.133:443,https://213.159.77.221:443,https://109.199.119.192:443,https://114.132.172.215:443,https://89.125.209.156:443", -]; - -const KNOWN_OLD_TESTNET_ADDRESSES: &[&str] = &[ - "https://34.214.48.68:1443,https://52.12.176.90:1443,https://52.34.144.50:1443,https://44.240.98.102:1443,https://54.201.32.131:1443,https://52.10.229.11:1443,https://52.13.132.146:1443,https://52.40.219.41:1443,https://54.149.33.167:1443,https://35.164.23.245:1443,https://52.33.28.47:1443,https://52.43.13.92:1443,https://52.89.154.48:1443,https://52.24.124.162:1443,https://35.85.21.179:1443,https://54.187.14.232:1443,https://54.68.235.201:1443,https://52.13.250.182:1443", -]; - #[derive(Debug, Deserialize, Clone)] pub struct Config { pub mainnet_config: Option, @@ -323,101 +309,6 @@ impl Config { }) } - /// Migrate old hardcoded DAPI addresses in the `.env` file to dynamic discovery. - /// - /// Scans for `MAINNET_dapi_addresses=` and `TESTNET_dapi_addresses=` lines whose - /// values match a known old hardcoded list. Matching lines are commented out so - /// that `envy` will no longer parse them and dynamic discovery kicks in. - pub fn migrate_env_file_if_needed(env_file_path: &Path) { - let content = match std::fs::read_to_string(env_file_path) { - Ok(c) => c, - Err(e) => { - eprintln!( - "[migration] Could not read .env file at {}: {e}", - env_file_path.display() - ); - return; - } - }; - - let mut changed = false; - let mut new_lines: Vec = Vec::new(); - - for line in content.lines() { - if let Some(value) = line.strip_prefix("MAINNET_dapi_addresses=") - && KNOWN_OLD_MAINNET_ADDRESSES - .iter() - .any(|old| value.trim() == *old) - { - new_lines.push(format!("# {line} # Migrated to dynamic discovery")); - changed = true; - continue; - } - if let Some(value) = line.strip_prefix("TESTNET_dapi_addresses=") - && KNOWN_OLD_TESTNET_ADDRESSES - .iter() - .any(|old| value.trim() == *old) - { - new_lines.push(format!("# {line} # Migrated to dynamic discovery")); - changed = true; - continue; - } - new_lines.push(line.to_string()); - } - - if !changed { - return; - } - - let parent_dir = match env_file_path.parent() { - Some(p) => p, - None => { - eprintln!( - "[migration] No parent directory for .env file at {}", - env_file_path.display() - ); - return; - } - }; - - let mut new_content = new_lines.join("\n"); - if content.ends_with('\n') { - new_content.push('\n'); - } - - let mut tmp = match NamedTempFile::new_in(parent_dir) { - Ok(t) => t, - Err(e) => { - eprintln!("[migration] Failed to create temp file for .env migration: {e}"); - return; - } - }; - if let Err(e) = tmp.write_all(new_content.as_bytes()) { - eprintln!("[migration] Failed to write migrated .env file: {e}"); - return; - } - if let Err(e) = tmp.as_file().sync_all() { - eprintln!("[migration] Failed to sync migrated .env file: {e}"); - return; - } - if let Err(e) = tmp.persist(env_file_path) { - eprintln!("[migration] Failed to persist migrated .env file: {e}"); - return; - } - - // Restrict file permissions on Unix (config contains RPC credentials). - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let perms = std::fs::Permissions::from_mode(0o600); - if let Err(e) = std::fs::set_permissions(env_file_path, perms) { - eprintln!("[migration] Could not set config file permissions to 0600: {e}"); - } - } - - eprintln!("[migration] Migrated old hardcoded DAPI addresses to dynamic discovery"); - } - /// Update (overwrite) the configuration for a particular network. pub fn update_config_for_network(&mut self, network: Network, new_config: NetworkConfig) { match network { diff --git a/src/context/mod.rs b/src/context/mod.rs index a75cb1f4a..d32ca295e 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -27,6 +27,7 @@ use arc_swap::ArcSwap; use connection_status::ConnectionStatus; use crossbeam_channel::{Receiver, Sender}; use dash_sdk::Sdk; +use dash_sdk::dapi_client::AddressList; use dash_sdk::dashcore_rpc::{Auth, Client, RpcApi}; use dash_sdk::dpp::dashcore::{Address, Network, Txid}; #[cfg(any(test, feature = "testing"))] @@ -43,6 +44,7 @@ use dash_sdk::platform::Identifier; use egui::Context; use std::collections::BTreeMap; use std::path::PathBuf; +use std::str::FromStr as _; use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, RwLock, RwLockWriteGuard}; @@ -159,15 +161,24 @@ impl AppContext { } }; - // Resolve DAPI addresses: try explicit config first (sync), fall back to discovery (async) - let address_list = match crate::dapi_discovery::resolve_dapi_addresses_sync( - network, - &network_config.dapi_addresses, - network_config.devnet_name.as_deref(), - ) { - Ok(list) => list, - Err(e) => { - tracing::error!(?network, error = %e, "Failed to resolve DAPI addresses"); + // Parse configured DAPI addresses directly (no auto-discovery at startup) + let address_list = match &network_config.dapi_addresses { + Some(addrs) if !addrs.trim().is_empty() => match AddressList::from_str(addrs.trim()) { + Ok(list) => list, + Err(e) => { + tracing::error!( + ?network, + error = %e, + "Failed to parse configured DAPI addresses" + ); + return None; + } + }, + _ => { + tracing::error!( + ?network, + "No DAPI addresses configured. Use Fetch Node List in Network Settings or add addresses to .env." + ); return None; } }; @@ -564,12 +575,21 @@ impl AppContext { detail: e.to_string(), })?; - // 3. Resolve DAPI addresses and rebuild the SDK - let address_list = crate::dapi_discovery::resolve_dapi_addresses_sync( - self.network, - &cfg.dapi_addresses, - cfg.devnet_name.as_deref(), - )?; + // 3. Parse DAPI addresses from config and rebuild the SDK + let address_list = match &cfg.dapi_addresses { + Some(addrs) if !addrs.trim().is_empty() => AddressList::from_str(addrs.trim()) + .map_err( + |source| crate::dapi_discovery::DapiDiscoveryError::InvalidAddresses { source }, + )?, + _ => { + return Err( + crate::dapi_discovery::DapiDiscoveryError::AddressesRequired { + network: self.network, + } + .into(), + ); + } + }; let new_sdk = match self.core_backend_mode() { CoreBackendMode::Spv => { diff --git a/src/dapi_discovery.rs b/src/dapi_discovery.rs index fb56a292a..bd9e81cf6 100644 --- a/src/dapi_discovery.rs +++ b/src/dapi_discovery.rs @@ -1,13 +1,13 @@ //! Dynamic DAPI node discovery via the Dash trusted masternode endpoint. //! -//! When no explicit DAPI addresses are configured in `.env`, this module +//! When the user clicks "Fetch Node List" in Network Settings, this module //! contacts a Dash-operated HTTPS service to discover available masternodes: //! //! - **Mainnet**: `https://quorums.mainnet.networks.dash.org/masternodes` //! - **Testnet**: `https://quorums.testnet.networks.dash.org/masternodes` //! //! Devnet and Regtest do **not** support dynamic discovery and require -//! explicit DAPI addresses in the `.env` configuration. +//! explicit DAPI addresses in the configuration. //! //! ## Trust model //! @@ -19,11 +19,8 @@ //! is accepted. Discovery is therefore a *convenience trust* layer, not a //! *security trust* layer -- incorrect results degrade availability but //! cannot forge Platform proofs. -//! -//! Results are not cached across restarts; discovery runs once per -//! `AppContext` initialization for the active network. -use dash_sdk::dapi_client::{Address as DapiAddress, AddressList, AddressListError}; +use dash_sdk::dapi_client::{Address as DapiAddress, AddressListError}; use dash_sdk::dpp::dashcore::Network; use rs_sdk_trusted_context_provider::{TrustedContextProviderError, TrustedHttpContextProvider}; use std::num::NonZeroUsize; @@ -48,10 +45,6 @@ pub enum DapiDiscoveryError { )] AddressesRequired { network: Network }, - /// No tokio runtime available for async discovery. - #[error("The application could not start node discovery. Please restart the application.")] - NoRuntime, - /// The trusted discovery provider could not be initialized. #[error("Could not start node discovery. Check your network connection and retry.")] ProviderInit { @@ -77,127 +70,14 @@ pub enum DapiDiscoveryError { NoResults, } -/// Hardcoded fallback seed nodes for mainnet (a small subset of known-good -/// masternodes). Used only when dynamic discovery fails so the app can still -/// start in a degraded state. -const MAINNET_SEED_NODES: &[&str] = &[ - "https://52.33.9.172:443", - "https://134.255.182.186:443", - "https://168.119.102.10:443", - "https://65.108.246.145:443", - "https://37.27.83.17:443", - "https://135.181.110.216:443", -]; - -/// Hardcoded fallback seed nodes for testnet (68.67.122.1–29, port 1443). -const TESTNET_SEED_NODES: &[&str] = &[ - "https://68.67.122.1:1443", - "https://68.67.122.2:1443", - "https://68.67.122.3:1443", - "https://68.67.122.4:1443", - "https://68.67.122.5:1443", - "https://68.67.122.6:1443", - "https://68.67.122.7:1443", - "https://68.67.122.8:1443", - "https://68.67.122.9:1443", - "https://68.67.122.10:1443", - "https://68.67.122.11:1443", - "https://68.67.122.12:1443", - "https://68.67.122.13:1443", - "https://68.67.122.14:1443", - "https://68.67.122.15:1443", - "https://68.67.122.16:1443", - "https://68.67.122.17:1443", - "https://68.67.122.18:1443", - "https://68.67.122.19:1443", - "https://68.67.122.20:1443", - "https://68.67.122.21:1443", - "https://68.67.122.22:1443", - "https://68.67.122.23:1443", - "https://68.67.122.24:1443", - "https://68.67.122.25:1443", - "https://68.67.122.26:1443", - "https://68.67.122.27:1443", - "https://68.67.122.28:1443", - "https://68.67.122.29:1443", -]; - -/// Resolve DAPI addresses synchronously. +/// Discover DAPI nodes from the DCG-operated HTTPS service. /// -/// If explicit addresses are configured, parses them directly. For mainnet and -/// testnet without explicit config, attempts dynamic discovery via -/// `tokio::task::block_in_place` (requires a tokio runtime). When discovery -/// fails for mainnet or testnet, falls back to hardcoded seed nodes so the -/// app can still start. Returns an error only if all resolution methods fail. -pub fn resolve_dapi_addresses_sync( - network: Network, - dapi_addresses: &Option, - devnet_name: Option<&str>, -) -> Result { - if let Some(addrs) = dapi_addresses { - let trimmed = addrs.trim(); - if !trimmed.is_empty() { - return AddressList::from_str(trimmed) - .map_err(|source| DapiDiscoveryError::InvalidAddresses { source }); - } - } - - match network { - Network::Mainnet | Network::Testnet => {} - _ => return Err(DapiDiscoveryError::AddressesRequired { network }), - } - - let discovery_result = match tokio::runtime::Handle::try_current() { - Ok(handle) => tokio::task::block_in_place(|| { - handle.block_on(try_discover_nodes(network, devnet_name)) - }), - Err(_) => Err(DapiDiscoveryError::NoRuntime), - }; - - match discovery_result { - Ok(list) => Ok(list), - Err(e) => { - tracing::warn!( - ?network, - error = %e, - "Dynamic DAPI discovery failed, falling back to seed nodes" - ); - fallback_seed_nodes(network) - } - } -} - -/// Build an `AddressList` from hardcoded seed nodes for the given network. -fn fallback_seed_nodes(network: Network) -> Result { - let seeds = match network { - Network::Mainnet => MAINNET_SEED_NODES, - Network::Testnet => TESTNET_SEED_NODES, - _ => return Err(DapiDiscoveryError::AddressesRequired { network }), - }; - - let mut list = AddressList::new(); - for seed in seeds { - if let Ok(addr) = DapiAddress::from_str(seed) { - list.add(addr); - } - } - - if list.is_empty() { - return Err(DapiDiscoveryError::NoResults); - } - - tracing::info!( - ?network, - count = list.len(), - "Using fallback seed nodes for DAPI" - ); - Ok(list) -} - -async fn try_discover_nodes( +/// Only supported for Mainnet and Testnet. Returns the valid address URLs +/// as strings, or an error if discovery fails. +pub async fn try_discover_nodes( network: Network, devnet_name: Option<&str>, -) -> Result { +) -> Result, DapiDiscoveryError> { let provider = TrustedHttpContextProvider::new( network, devnet_name.map(|s| s.to_string()), @@ -213,25 +93,23 @@ async fn try_discover_nodes( .map_err(|_| DapiDiscoveryError::Timeout)? .map_err(|source| DapiDiscoveryError::RequestFailed { source })?; - let mut address_list = AddressList::new(); + let mut valid_urls = Vec::new(); for url in urls { match DapiAddress::from_str(url.as_str()) { - Ok(addr) => { - address_list.add(addr); - } + Ok(_) => valid_urls.push(url.to_string()), Err(e) => tracing::debug!(url = %url, error = %e, "Skipping invalid address"), } } - if address_list.is_empty() { + if valid_urls.is_empty() { return Err(DapiDiscoveryError::NoResults); } tracing::info!( ?network, - count = address_list.len(), + count = valid_urls.len(), "Discovered DAPI nodes dynamically" ); - Ok(address_list) + Ok(valid_urls) } diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index 0bdf6d078..0d71d6aef 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -1,7 +1,7 @@ use crate::app::AppAction; -use crate::backend_task::BackendTask; use crate::backend_task::core::CoreTask; use crate::backend_task::system_task::SystemTask; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::config::Config; use crate::context::AppContext; use crate::context::connection_status::{ConnectionStatus, OverallConnectionState}; @@ -110,6 +110,8 @@ pub struct NetworkChooserScreen { use_local_spv_node: bool, auto_start_spv: bool, close_dash_qt_on_exit: bool, + discovery_in_progress: bool, + show_fetch_confirmation: bool, } impl NetworkChooserScreen { @@ -209,6 +211,8 @@ impl NetworkChooserScreen { use_local_spv_node, auto_start_spv, close_dash_qt_on_exit, + discovery_in_progress: false, + show_fetch_confirmation: false, } } @@ -441,6 +445,118 @@ impl NetworkChooserScreen { ui.end_row(); }); + // Node Addresses section + { + ui.add_space(20.0); + ui.separator(); + ui.add_space(12.0); + + ui.label( + egui::RichText::new("Node Addresses") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(8.0); + + let ctx_ref = self.current_app_context().clone(); + let status = ctx_ref.connection_status(); + let dapi_total = status.dapi_total_endpoints(); + let dapi_available = status.dapi_available_endpoints(); + + ui.horizontal(|ui| { + let status_text = + format!("DAPI: {dapi_available}/{dapi_total} reachable"); + let status_color = if dapi_available > 0 { + DashColors::SUCCESS + } else if dapi_total > 0 { + DashColors::WARNING + } else { + DashColors::text_secondary(dark_mode) + }; + ui.colored_label(status_color, &status_text); + + let is_discoverable = matches!( + self.current_network, + Network::Mainnet | Network::Testnet + ); + + if is_discoverable { + let button_text = if self.discovery_in_progress { + "Fetching..." + } else { + "Fetch Node List" + }; + let button = egui::Button::new(button_text) + .corner_radius(Shape::RADIUS_MD); + + if ui + .add_enabled(!self.discovery_in_progress, button) + .clicked() + { + if dapi_total > 0 { + self.show_fetch_confirmation = true; + } else { + self.discovery_in_progress = true; + app_action = AppAction::BackendTask( + BackendTask::DiscoverDapiNodes { + network: self.current_network, + }, + ); + } + } + + ui.add_space(4.0); + let info_label = ui.label( + egui::RichText::new("(i)") + .color(DashColors::text_secondary(dark_mode)) + .size(12.0), + ); + info_label.on_hover_text( + "Fetches available nodes from a service operated by Dash Core Group (DCG) over HTTPS. This is a convenience service -- Platform proofs are verified independently.", + ); + } else { + ui.label( + egui::RichText::new( + "Enter node addresses manually for this network.", + ) + .color(DashColors::text_secondary(dark_mode)) + .size(12.0), + ); + } + }); + } + + // Fetch confirmation dialog + if self.show_fetch_confirmation { + let ctx_ref = self.current_app_context().clone(); + let dapi_total = ctx_ref.connection_status().dapi_total_endpoints(); + + egui::Window::new("Update Node Addresses?") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) + .show(ui.ctx(), |ui| { + ui.label(format!( + "This will replace your current {dapi_total} node addresses with a fresh list from the Dash network service." + )); + ui.add_space(12.0); + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + self.show_fetch_confirmation = false; + } + if ui.button("Fetch").clicked() { + self.show_fetch_confirmation = false; + self.discovery_in_progress = true; + app_action = AppAction::BackendTask( + BackendTask::DiscoverDapiNodes { + network: self.current_network, + }, + ); + } + }); + }); + } + // Password input for RPC mode (any network) let current_backend_mode = *self .backend_modes @@ -2190,4 +2306,63 @@ impl ScreenLike for NetworkChooserScreen { action } + + fn display_message(&mut self, _message: &str, _message_type: MessageType) { + self.discovery_in_progress = false; + } + + fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { + if let BackendTaskSuccessResult::DapiNodesDiscovered { + network, + count, + addresses_csv, + } = backend_task_success_result + { + self.discovery_in_progress = false; + + // Update config with new addresses + if let Ok(mut config) = Config::load_from(&self.mainnet_app_context.data_dir) + && let Some(mut network_cfg) = config.config_for_network(network).clone() + { + network_cfg.dapi_addresses = Some(addresses_csv); + config.update_config_for_network(network, network_cfg.clone()); + + if let Err(e) = config.save(&self.mainnet_app_context.data_dir) { + tracing::error!("Failed to save config after DAPI discovery: {e}"); + } + + // 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, + }; + + if network_context_exists { + let app_context = self.context_for_network(network); + { + if let Ok(mut cfg_lock) = app_context.config.write() { + *cfg_lock = network_cfg; + } + } + + if let Err(e) = Arc::clone(app_context).reinit_core_client_and_sdk() { + tracing::error!( + "Failed to reinit SDK after DAPI discovery for {:?}: {}", + network, + e + ); + } + } + + MessageBanner::set_global( + self.mainnet_app_context.egui_ctx(), + format!("Updated to {count} node addresses."), + MessageType::Success, + ); + } + } + } } From ee523f8df7c52d1dc5c63a4e69e0826887837cea Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 17:18:17 +0200 Subject: [PATCH 22/48] fix(app): gracefully handle missing DAPI addresses at startup Instead of crashing when the saved network has no DAPI addresses configured, try other networks as fallback. If no network can be initialized, show an actionable error message pointing to the "Fetch Node List" button in Network Settings. Co-Authored-By: Claude Opus 4.6 --- src/app.rs | 46 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/src/app.rs b/src/app.rs index daa0b90b0..812d7d96a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -327,15 +327,44 @@ impl AppState { // 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. - let active_context = make_context(saved_network).ok_or_else(|| { - format!( - "Failed to create AppContext for {:?}. Check your Dash configuration.", - saved_network - ) - })?; - + // + // 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(); - network_contexts.insert(saved_network, active_context.clone()); + 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(); // Store params for lazy context creation when switching networks later. let lazy_ctx_params = Some(LazyContextParams { @@ -400,7 +429,6 @@ impl AppState { let wallets_balances_screen = WalletsBalancesScreen::new(&active_context); let selected_main_screen = settings.root_screen_type; - let chosen_network = saved_network; // // Create a channel with a buffer size of 32 (adjust as needed) let (task_result_sender, task_result_receiver) = From 344183a099f8888982a2376e780f25d20916876d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 17:26:38 +0200 Subject: [PATCH 23/48] fix(ui): move fetch button to Connection Status, unify 3-line display - Remove separate "Node Addresses" section - Move "Fetch Node List" button into Connection Status card, below DAPI line - Unify non-expert and expert modes to always use 3-line status display (Core RPC / ZMQ / DAPI, or SPV / DAPI) - Button tooltip: "Updates list of DAPI nodes using a centralized server managed by Dash Core Group." - Confirmation dialog explicitly mentions centralized DCG server Co-Authored-By: Claude Opus 4.6 --- src/ui/network_chooser_screen.rs | 239 +++++++++++-------------------- 1 file changed, 80 insertions(+), 159 deletions(-) diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index 0d71d6aef..feb7be737 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -445,118 +445,6 @@ impl NetworkChooserScreen { ui.end_row(); }); - // Node Addresses section - { - ui.add_space(20.0); - ui.separator(); - ui.add_space(12.0); - - ui.label( - egui::RichText::new("Node Addresses") - .strong() - .color(DashColors::text_primary(dark_mode)), - ); - ui.add_space(8.0); - - let ctx_ref = self.current_app_context().clone(); - let status = ctx_ref.connection_status(); - let dapi_total = status.dapi_total_endpoints(); - let dapi_available = status.dapi_available_endpoints(); - - ui.horizontal(|ui| { - let status_text = - format!("DAPI: {dapi_available}/{dapi_total} reachable"); - let status_color = if dapi_available > 0 { - DashColors::SUCCESS - } else if dapi_total > 0 { - DashColors::WARNING - } else { - DashColors::text_secondary(dark_mode) - }; - ui.colored_label(status_color, &status_text); - - let is_discoverable = matches!( - self.current_network, - Network::Mainnet | Network::Testnet - ); - - if is_discoverable { - let button_text = if self.discovery_in_progress { - "Fetching..." - } else { - "Fetch Node List" - }; - let button = egui::Button::new(button_text) - .corner_radius(Shape::RADIUS_MD); - - if ui - .add_enabled(!self.discovery_in_progress, button) - .clicked() - { - if dapi_total > 0 { - self.show_fetch_confirmation = true; - } else { - self.discovery_in_progress = true; - app_action = AppAction::BackendTask( - BackendTask::DiscoverDapiNodes { - network: self.current_network, - }, - ); - } - } - - ui.add_space(4.0); - let info_label = ui.label( - egui::RichText::new("(i)") - .color(DashColors::text_secondary(dark_mode)) - .size(12.0), - ); - info_label.on_hover_text( - "Fetches available nodes from a service operated by Dash Core Group (DCG) over HTTPS. This is a convenience service -- Platform proofs are verified independently.", - ); - } else { - ui.label( - egui::RichText::new( - "Enter node addresses manually for this network.", - ) - .color(DashColors::text_secondary(dark_mode)) - .size(12.0), - ); - } - }); - } - - // Fetch confirmation dialog - if self.show_fetch_confirmation { - let ctx_ref = self.current_app_context().clone(); - let dapi_total = ctx_ref.connection_status().dapi_total_endpoints(); - - egui::Window::new("Update Node Addresses?") - .collapsible(false) - .resizable(false) - .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) - .show(ui.ctx(), |ui| { - ui.label(format!( - "This will replace your current {dapi_total} node addresses with a fresh list from the Dash network service." - )); - ui.add_space(12.0); - ui.horizontal(|ui| { - if ui.button("Cancel").clicked() { - self.show_fetch_confirmation = false; - } - if ui.button("Fetch").clicked() { - self.show_fetch_confirmation = false; - self.discovery_in_progress = true; - app_action = AppAction::BackendTask( - BackendTask::DiscoverDapiNodes { - network: self.current_network, - }, - ); - } - }); - }); - } - // Password input for RPC mode (any network) let current_backend_mode = *self .backend_modes @@ -853,7 +741,7 @@ impl NetworkChooserScreen { ui.add_space(10.0); ui.vertical(|ui| { - if current_backend_mode == CoreBackendMode::Rpc && !self.developer_mode { + if current_backend_mode == CoreBackendMode::Rpc { ui.horizontal(|ui| { ui.label("Core RPC:"); let rpc_color = if rpc_online { @@ -869,42 +757,6 @@ impl NetworkChooserScreen { "Disconnected".to_string() }; ui.colored_label(rpc_color, &rpc_label); - - ui.label(","); - ui.label("ZMQ:"); - if disable_zmq { - ui.colored_label(DashColors::text_secondary(dark_mode), "Disabled"); - } else { - let zmq_color = if zmq_connected { - DashColors::SUCCESS - } else { - DashColors::ERROR - }; - let zmq_label = if zmq_connected { "Connected" } else { "Disconnected" }; - ui.colored_label(zmq_color, zmq_label); - } - - ui.label(","); - add_dapi_status_label(ui, dapi_total, dapi_available, &dapi_label, dark_mode); - }); - } - - if current_backend_mode == CoreBackendMode::Rpc && self.developer_mode { - ui.horizontal(|ui| { - ui.label("Dash Core RPC:"); - let color = if rpc_online { - DashColors::SUCCESS - } else { - DashColors::ERROR - }; - let label = if rpc_online { - "Connected".to_string() - } else if let Some(ref err) = rpc_last_error { - format!("Error: {err}") - } else { - "Disconnected".to_string() - }; - ui.colored_label(color, &label); }); ui.horizontal(|ui| { @@ -915,19 +767,16 @@ impl NetworkChooserScreen { "Disabled", ); } else { - let color = if zmq_connected { + let zmq_color = if zmq_connected { DashColors::SUCCESS } else { DashColors::ERROR }; - let label = if zmq_connected { "Connected" } else { "Disconnected" }; - ui.colored_label(color, label); + let zmq_label = + if zmq_connected { "Connected" } else { "Disconnected" }; + ui.colored_label(zmq_color, zmq_label); } }); - - ui.horizontal(|ui| { - add_dapi_status_label(ui, dapi_total, dapi_available, &dapi_label, dark_mode); - }); } if current_backend_mode == CoreBackendMode::Spv { @@ -948,12 +797,84 @@ impl NetworkChooserScreen { }; ui.colored_label(color, label); }); + } - ui.horizontal(|ui| { - add_dapi_status_label(ui, dapi_total, dapi_available, &dapi_label, dark_mode); - }); + // DAPI line (all modes) + ui.horizontal(|ui| { + add_dapi_status_label( + ui, + dapi_total, + dapi_available, + &dapi_label, + dark_mode, + ); + }); + + // "Fetch Node List" button — Mainnet/Testnet only + let is_discoverable = matches!( + self.current_network, + Network::Mainnet | Network::Testnet + ); + if is_discoverable { + ui.add_space(8.0); + let button_text = if self.discovery_in_progress { + "Fetching..." + } else { + "Fetch Node List" + }; + let button = + egui::Button::new(button_text).corner_radius(Shape::RADIUS_MD); + + let response = ui.add_enabled(!self.discovery_in_progress, button); + let clicked = response.clicked(); + response.on_hover_text( + "Updates list of DAPI nodes using a centralized server managed by Dash Core Group.", + ); + if clicked { + if dapi_total > 0 { + self.show_fetch_confirmation = true; + } else { + self.discovery_in_progress = true; + app_action = AppAction::BackendTask( + BackendTask::DiscoverDapiNodes { + network: self.current_network, + }, + ); + } + } } }); + + // Fetch confirmation dialog + if self.show_fetch_confirmation { + egui::Window::new("Update Node Addresses?") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) + .show(ui.ctx(), |ui| { + ui.label(format!( + "This will fetch a fresh list of DAPI nodes from a centralized \ + server managed by Dash Core Group, replacing your current {} \ + configured addresses.", + dapi_total + )); + ui.add_space(12.0); + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + self.show_fetch_confirmation = false; + } + if ui.button("Fetch").clicked() { + self.show_fetch_confirmation = false; + self.discovery_in_progress = true; + app_action = AppAction::BackendTask( + BackendTask::DiscoverDapiNodes { + network: self.current_network, + }, + ); + } + }); + }); + } }); // Advanced Settings section with clean dropdown From 9ad00765c1aee7e65dbf6729a439e6915acf63a7 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 17:38:42 +0200 Subject: [PATCH 24/48] fix(shielded): gate shielded wallet init on protocol version support Skip shielded wallet initialization and note sync on networks that don't support shielded transactions (protocol version < 12). This prevents unnecessary sync attempts and log noise on mainnet, which doesn't have shielded support yet. Co-Authored-By: Claude Opus 4.6 --- src/context/wallet_lifecycle.rs | 34 ++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index 98009c7d7..de488bacb 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -161,25 +161,25 @@ impl AppContext { // Note: Platform address sync is not done here. // Core UTXO refresh is handled at startup in bootstrap_loaded_wallets. - // Eagerly initialize shielded wallet state so that the cached - // balance (from persisted notes) is available to all UI screens - // immediately, without requiring the user to visit the Shielded tab. - // Then queue async SyncNotes -> CheckNullifiers to refresh from - // the network. This is the single init path — the UI never - // dispatches InitializeShieldedWallet. - match self.initialize_shielded_wallet(seed_hash) { - Ok(_) => { - tracing::trace!( + // Initialize shielded wallet state only when the network supports it + // (protocol version >= 12, i.e., Platform v3.1+). On mainnet (which + // doesn't support shielded transactions yet), skip entirely to avoid + // unnecessary sync attempts and log noise. + if self.supports_shielded() { + match self.initialize_shielded_wallet(seed_hash) { + Ok(_) => { + tracing::trace!( + seed = %hex::encode(seed_hash), + "Shielded wallet state initialized on unlock" + ); + self.queue_shielded_sync(seed_hash); + } + Err(e) => tracing::debug!( seed = %hex::encode(seed_hash), - "Shielded wallet state initialized on unlock" - ); - self.queue_shielded_sync(seed_hash); + error = %e, + "Shielded wallet init skipped on unlock" + ), } - Err(e) => tracing::debug!( - seed = %hex::encode(seed_hash), - error = %e, - "Shielded wallet init skipped on unlock" - ), } } } From 6b83b8a106882994728178d4086e5be432dca704 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 17:47:50 +0200 Subject: [PATCH 25/48] chore: rename button to Refresh DAPI endpoints --- src/context/mod.rs | 2 +- src/dapi_discovery.rs | 2 +- src/ui/network_chooser_screen.rs | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/context/mod.rs b/src/context/mod.rs index 2546ff66d..56b3fcee2 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -177,7 +177,7 @@ impl AppContext { _ => { tracing::error!( ?network, - "No DAPI addresses configured. Use Fetch Node List in Network Settings or add addresses to .env." + "No DAPI addresses configured. Use Refresh DAPI endpoints in Network Settings or add addresses to .env." ); return None; } diff --git a/src/dapi_discovery.rs b/src/dapi_discovery.rs index bd9e81cf6..99e547649 100644 --- a/src/dapi_discovery.rs +++ b/src/dapi_discovery.rs @@ -1,6 +1,6 @@ //! Dynamic DAPI node discovery via the Dash trusted masternode endpoint. //! -//! When the user clicks "Fetch Node List" in Network Settings, this module +//! When the user clicks "Refresh DAPI endpoints" in Network Settings, this module //! contacts a Dash-operated HTTPS service to discover available masternodes: //! //! - **Mainnet**: `https://quorums.mainnet.networks.dash.org/masternodes` diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index feb7be737..f48752fa9 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -810,7 +810,7 @@ impl NetworkChooserScreen { ); }); - // "Fetch Node List" button — Mainnet/Testnet only + // "Refresh DAPI endpoints" button — Mainnet/Testnet only let is_discoverable = matches!( self.current_network, Network::Mainnet | Network::Testnet @@ -820,7 +820,7 @@ impl NetworkChooserScreen { let button_text = if self.discovery_in_progress { "Fetching..." } else { - "Fetch Node List" + "Refresh DAPI endpoints" }; let button = egui::Button::new(button_text).corner_radius(Shape::RADIUS_MD); From 1ffc6d2f552986dfd921c9de98e89345a3e66ec7 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 17:58:33 +0200 Subject: [PATCH 26/48] refactor: move dapi_discovery.rs to backend_task module Move dapi_discovery.rs from crate root to src/backend_task/ where it logically belongs alongside the BackendTask::DiscoverDapiNodes handler. Add discover_and_format() helper that combines discovery + CSV formatting for simpler use from the task handler. Co-Authored-By: Claude Opus 4.6 --- .../ux-spec.md | 396 ++++++++++++++ .../wireframe.html | 515 ++++++++++++++++++ src/{ => backend_task}/dapi_discovery.rs | 14 + src/backend_task/error.rs | 2 +- src/backend_task/mod.rs | 28 +- src/context/mod.rs | 10 +- src/lib.rs | 1 - 7 files changed, 945 insertions(+), 21 deletions(-) create mode 100644 docs/ai-design/2026-03-30-dapi-discovery-button/ux-spec.md create mode 100644 docs/ai-design/2026-03-30-dapi-discovery-button/wireframe.html rename src/{ => backend_task}/dapi_discovery.rs (90%) diff --git a/docs/ai-design/2026-03-30-dapi-discovery-button/ux-spec.md b/docs/ai-design/2026-03-30-dapi-discovery-button/ux-spec.md new file mode 100644 index 000000000..590c3de17 --- /dev/null +++ b/docs/ai-design/2026-03-30-dapi-discovery-button/ux-spec.md @@ -0,0 +1,396 @@ +# UX Specification: Manual DAPI Node Discovery Button + +**Date**: 2026-03-30 +**Feature**: User-triggered node address discovery in Network Settings +**Status**: Draft + +--- + +## 1. Problem Statement + +Dash Evo Tool connects to the Dash Platform network through DAPI nodes (masternodes). Currently, node addresses are configured in a `.env` file and, for Mainnet/Testnet, discovered automatically at startup from a DCG-operated HTTPS endpoint. The app is moving to a manual, user-triggered discovery model where: + +- No automatic discovery at startup -- the app always uses addresses from its config +- A button in the Network Settings screen lets users fetch current node addresses on demand +- Users understand they are fetching from a centralized service (DCG), not from the blockchain itself + +## 2. Personas and Walkthrough + +### Alex (Everyday User) + +Alex opens Network Settings because the app said it could not connect to the network. Alex sees the Connection Settings card and notices a message near the empty node address area: "No node addresses configured." Next to it is a clearly labeled button. Alex clicks it, sees a brief spinner, and the field fills with addresses. Alex clicks Save and the app connects. Alex never needs to understand what DAPI means or where the addresses came from. + +**Key need**: A single clear action that fixes the "can't connect" problem. No jargon. + +### Priya (Power User) + +Priya opens Network Settings to refresh her node list after a network upgrade. She sees the "Fetch Node List" button and the info icon next to it. She hovers the info icon and reads that addresses come from a DCG-operated service. She clicks the button. A confirmation dialog appears because she already has addresses configured -- it tells her the current count and the count about to be fetched. She confirms, reviews the new addresses in the text field, and saves. + +**Key need**: Transparency about what is happening, confirmation before overwriting, ability to review and edit the result. + +### Jordan (Platform Developer) + +Jordan is on a Devnet tab. The Fetch Node List button is not visible -- Jordan knows devnet addresses must be entered manually. On Testnet, Jordan uses the button occasionally but is just as likely to paste addresses directly. Jordan appreciates that the button does not auto-save -- it populates the field and lets Jordan edit before committing. + +**Key need**: Button stays out of the way on devnets. Does not auto-save; just populates. + +## 3. Design + +### 3.1 Placement + +The discovery button lives in a new **"Node Addresses"** section within the existing **Connection Settings** card, placed immediately below the Network selector row (and SPV warning, if shown) and above the Core RPC Password section. This section is visible for **all networks** but the button is only available for Mainnet and Testnet. + +``` +Connection Settings Card ++-------------------------------------------------------+ +| Connection Type: [SPV Client v] (dev mode only) | +| Network: [Mainnet v] | +| | +| --- Node Addresses --- | +| [multiline text field with current DAPI addresses] | +| [Fetch Node List] (i) | +| ^secondary btn ^info icon with tooltip | +| | +| --- Core RPC Password --- (RPC mode only) | +| [password field] [Save] | ++-------------------------------------------------------+ +``` + +**Layout details:** +- Section label: "Node Addresses" -- rendered as a bold subheading (`RichText::new(...).strong().color(text_primary)`) +- Text field: multiline `TextEdit` showing the current `dapi_addresses` value (comma-separated URLs). Editable. Approximately 3-4 lines tall. Uses `styled_text_edit_multiline()` with standard input strokes. +- Below the text field, a horizontal row containing the button and info icon +- Spacing follows existing patterns: `add_space(12.0)` before section, `add_space(8.0)` between label and field, `add_space(8.0)` between field and button row + +### 3.2 Button Design + +| Property | Value | +|----------|-------| +| **Label** | "Fetch Node List" | +| **Variant** | Secondary (outlined, `StyledButton` `ButtonVariant::Secondary`) | +| **Size** | Medium | +| **Icon** | None (egui icon support is limited; keep it text-only) | +| **Min width** | 160px | + +**Rationale for "Fetch Node List"**: +- Avoids "DAPI" jargon -- Alex does not know what DAPI is +- "Fetch" communicates a network operation (important for trust/timing expectations) +- "Node List" is more concrete than "Discover Nodes" -- it describes what you get +- Secondary variant ensures it does not compete visually with the primary "Save" button in the password section + +**Visibility rules:** +- **Mainnet, Testnet**: Button visible and enabled +- **Devnet, Regtest**: Button hidden. In its place, a small caption: "Enter node addresses manually for this network." + +### 3.3 Info Icon and Trust Disclosure + +To the right of the button, render a small info label `(i)` with an `info_tooltip()` (uses the Help cursor per UX design patterns). + +**Tooltip text:** + +> Fetches the current list of available nodes from a service operated by Dash Core Group (DCG). This is a convenience service over HTTPS -- it does not access the blockchain directly. Platform proofs are verified independently, so incorrect node addresses cannot forge data, but they could prevent the app from connecting. + +**Rationale**: Progressive disclosure. Alex never reads it. Priya hovers once, understands the trust model, and is satisfied. Jordan already knows but appreciates the precision. + +### 3.4 Empty State + +When the text field is empty AND the current network is Mainnet or Testnet, show a hint message below the text field (above the button row): + +``` +[text field -- empty, showing placeholder "No node addresses configured"] + + Use "Fetch Node List" to get the current addresses, or enter them manually. + +[Fetch Node List] (i) +``` + +The hint text is rendered in `DashColors::text_secondary(dark_mode)`, `Typography::SCALE_SM`, italics. It disappears once the field has content. + +For Devnet/Regtest with an empty field: + +``` +[text field -- empty, showing placeholder "No node addresses configured"] + + Enter node addresses for this network (comma-separated URLs). +``` + +### 3.5 Loading State + +When the user clicks "Fetch Node List": + +1. Button text changes to "Fetching..." with a spinner (`ui.spinner()`) to the left of the button text +2. Button is disabled (prevents double-submit per UX patterns) +3. Text field remains visible and read-only during the fetch (not disabled -- just non-editable, so the user can see existing content) +4. Expected duration is 1-10 seconds per the discovery module's 10-second timeout + +**Implementation note**: This should be dispatched as a `BackendTask` to avoid blocking the UI thread. The screen stores a `discovery_in_progress: bool` flag. The discovery result arrives through the standard `display_task_result()` path. + +### 3.6 Success State (No Existing Addresses) + +When the field was empty before the fetch: + +1. Text field is populated with the fetched addresses (comma-separated) +2. Button returns to default state +3. A success banner appears: "Found {count} node addresses. Review them below and save your settings." +4. The field is **not** auto-saved -- the user must click the existing Save mechanism (or the addresses are saved when the network config is saved) + +**Important**: The fetched addresses populate the field but do not persist until the user explicitly saves. This gives all personas a chance to review and edit. + +### 3.7 Success State (Existing Addresses -- Confirmation Dialog) + +When the field already contains addresses and the user clicks "Fetch Node List": + +1. A `ConfirmationDialog` appears before the fetch begins +2. Dialog content: + +``` ++---------------------------------------------------+ +| Update Node Addresses? | +| | +| This will replace your current node addresses | +| with a fresh list fetched from the Dash network | +| service. | +| | +| You currently have {N} addresses configured. | +| You can review and edit the new list before | +| saving. | +| | +| [Cancel] [Fetch] | ++---------------------------------------------------+ +``` + +| Dialog property | Value | +|-----------------|-------| +| Title | "Update Node Addresses?" | +| Confirm label | "Fetch" | +| Cancel label | "Cancel" | +| Danger mode | No (this is not destructive -- old addresses are only replaced in the field, not saved) | +| Escape/X | Cancels | + +**After confirmation**, the fetch proceeds and the field is populated with new addresses. The old addresses are replaced in the field but not persisted until Save. + +### 3.8 Error State + +When the fetch fails: + +1. Button returns to default state ("Fetch Node List", enabled) +2. An error banner appears via `MessageBanner::set_global()`: + - **Timeout**: "Node list fetch timed out. Check your internet connection and try again." + - **Network error**: "Could not fetch the node list. Check your internet connection and try again." + - **No results**: "No available nodes were found. The network may be temporarily unavailable -- try again later." + - **Other**: "Could not fetch the node list. Try again, or enter node addresses manually." +3. Existing addresses in the field are **not** modified on error + +These messages align with the existing `DapiDiscoveryError` variants in `src/dapi_discovery.rs`. + +### 3.9 Interaction with Save + +The node addresses field needs a Save mechanism. Two options: + +**Recommended approach**: Add the node addresses field to the existing config save flow. When the Core RPC Password "Save" button is clicked (or a new dedicated "Save" is added for this section), persist the current contents of the node addresses field to the `.env` config file using `Config::update_config_for_network()`. + +If the Node Addresses section is above the Core RPC Password section, adding a small "Save" button on the same row as the Fetch button keeps the interaction local: + +``` +[Fetch Node List] (i) [Save] +``` + +The Save button here: +- Uses `StyledButton` Secondary variant, same size as Fetch +- Saves the current text field content to the config file +- Shows success/error via `MessageBanner` +- Is always enabled (even if the field has not changed -- simpler, and egui does not trivially track dirty state) + +### 3.10 State Diagram + +``` + +------------------+ + | Default State | + | Field: current | + | Button: enabled | + +--------+---------+ + | + User clicks "Fetch Node List" + | + +--------v---------+ + yes | Field has | no + +--------+ existing addrs? +--------+ + | +------------------+ | + v v + +--------+---------+ +-----------+--------+ + | Confirmation | Cancel | Fetching State | + | Dialog shown +---+ | Button: disabled | + +--------+---------+ | | Spinner shown | + | | +---------+----------+ + Confirm | | + | | +--------v---------+ + v | yes | Fetch result? | no + +--------+---------+ | +-----+ +-----+ + | Fetching State | | | +------------------+ | + | Button: disabled | | v v + | Spinner shown | | +-+-------------+ +-----------+-+ + +--------+---------+ | | Success | | Error | + | | | Field updated | | Field stays | + | | | Banner: info | | Banner: err | + +--------v---------+ | +------+--------+ +------+------+ + | Fetch result? | | | | + +--+------------+--+ | v v + yes no | +----+--------------------+----+ + | | | | Default State | + v v +-->| (user can now Save) | + Success Error +------------------------------+ +``` + +## 4. Component Specification + +### 4.1 Node Address Text Field + +``` +Component: NodeAddressField +Purpose: Display and edit the comma-separated list of DAPI node URLs +Type: multiline TextEdit (styled_text_edit_multiline) +States: + - default: editable, shows current addresses + - empty: shows placeholder "No node addresses configured" + - read-only-during-fetch: text visible but not editable +Responsive: full available width, 3-4 lines height (approximately 80px) +Accessibility: + - Tab-focusable + - Placeholder text visible when empty +``` + +### 4.2 Fetch Node List Button + +``` +Component: StyledButton (Secondary variant) +Label: "Fetch Node List" +States: + - default: outlined secondary button, enabled + - hover: pointing hand cursor (automatic from StyledButton) + - loading: text changes to "Fetching...", disabled, spinner adjacent + - hidden: on Devnet/Regtest networks +Min width: 160px +Accessibility: + - Tab-focusable + - disabled_tooltip when loading: "Fetching node addresses..." +``` + +### 4.3 Info Icon + +``` +Component: label "(i)" with info_tooltip() +Purpose: Trust disclosure for the discovery service +States: + - default: subtle text in text_secondary color + - hover: Help cursor, tooltip shown +Accessibility: + - info_tooltip provides the help cursor automatically +``` + +### 4.4 Save Button (Node Addresses) + +``` +Component: StyledButton (Secondary variant) +Label: "Save" +Purpose: Persist node addresses to config file +States: + - default: enabled + - hover: pointing hand cursor +Placement: right-aligned on the button row +``` + +### 4.5 Confirmation Dialog + +``` +Component: ConfirmationDialog (existing pattern) +Title: "Update Node Addresses?" +Body: explains what will happen, shows current address count +Confirm label: "Fetch" +Cancel label: "Cancel" +Danger mode: false +Trigger: clicking Fetch when field already has content +``` + +## 5. Screen State Additions + +New fields on `NetworkChooserScreen`: + +```rust +/// Current text in the node addresses field per network +node_addresses_text: HashMap, + +/// Whether a discovery fetch is in progress +discovery_in_progress: bool, + +/// Confirmation dialog for overwriting existing addresses +discovery_confirm_dialog: Option, +``` + +On construction, `node_addresses_text` is populated from `Config::load_from()` for each network's `dapi_addresses` field. + +## 6. Backend Task + +A new `SystemTask` variant handles the async discovery: + +```rust +SystemTask::DiscoverDapiNodes { network: Network } +``` + +This calls the existing `try_discover_nodes()` from `src/dapi_discovery.rs` (the async variant, not the sync one with fallback). The result is returned as a new `BackendTaskSuccessResult` variant: + +```rust +BackendTaskSuccessResult::DapiNodesDiscovered { + network: Network, + addresses: Vec, +} +``` + +The screen's `display_task_result()` handler populates `node_addresses_text[network]` with the comma-separated result and shows the success banner. + +## 7. Accessibility + +| Requirement | Implementation | +|-------------|----------------| +| Keyboard navigation | All elements (text field, buttons, info icon) are Tab-focusable in layout order | +| Focus indicator | Standard egui focus ring (BORDER_WIDTH_THICK per UX patterns) | +| Tooltips | info_tooltip on (i) icon, disabled_tooltip on button during fetch | +| Screen readers | egui has limited a11y; no additional ARIA annotations possible | +| Color contrast | All text meets WCAG AA (inherited from DashColors theme system) | +| Click targets | Buttons use StyledButton which meets WCAG AA minimum targets | + +## 8. Responsive Behavior + +The Node Addresses section uses `ui.available_width()` for the text field (full width). The button row is horizontal with the Fetch button left-aligned and Save button right-aligned. On narrow windows, buttons wrap naturally via egui's horizontal layout (they will stack if space is insufficient, which is acceptable for this secondary feature). + +## 9. Edge Cases + +| Case | Behavior | +|------|----------| +| User clicks Fetch, then switches network tab during fetch | The result is tagged with the network it was fetched for. If the user has switched networks, the result populates `node_addresses_text[original_network]` silently. The banner still appears. | +| Fetch returns identical addresses to what was already configured | Treat as success. Field is updated (same content). Banner shows the count. | +| User edits the field manually, then clicks Fetch | Confirmation dialog appears (field has content). Fetch replaces the manual edits in the field. | +| User clicks Fetch, gets results, then clicks Fetch again without saving | Confirmation dialog appears (field has content from the first fetch). Second fetch replaces. | +| Config file is read-only or missing | Save button shows error banner: "Could not save settings. Check that the application folder is writable and retry." (matches existing `ConfigError::SaveError` pattern). | +| Discovery returns hundreds of addresses | All addresses populate the field. The multiline TextEdit scrolls. User can edit to trim if desired. | + +## 10. What This Spec Does NOT Cover + +- Automatic migration of old hardcoded addresses (handled separately in `config.rs`) +- Changes to the startup discovery flow (separate task) +- The `.env` file format or config parsing (existing code in `Config`) +- MCP/CLI exposure of discovery (out of scope for UI spec) + +## 11. Implementation Checklist + +- [ ] Add `node_addresses_text: HashMap` and related fields to `NetworkChooserScreen` +- [ ] Render the Node Addresses section in `render_network_table()` between Network selector and Core RPC Password +- [ ] Implement `StyledButton::secondary()` variant usage for Fetch and Save buttons +- [ ] Add `SystemTask::DiscoverDapiNodes` backend task variant +- [ ] Add `BackendTaskSuccessResult::DapiNodesDiscovered` result variant +- [ ] Wire `display_task_result()` to populate the text field on success +- [ ] Add `ConfirmationDialog` for overwrite confirmation +- [ ] Add Save button that persists `node_addresses_text` to config +- [ ] Hide Fetch button and show manual-entry hint on Devnet/Regtest +- [ ] Show empty-state guidance when no addresses are configured +- [ ] Test light and dark themes +- [ ] Verify keyboard navigation (Tab through field, Fetch, Save) diff --git a/docs/ai-design/2026-03-30-dapi-discovery-button/wireframe.html b/docs/ai-design/2026-03-30-dapi-discovery-button/wireframe.html new file mode 100644 index 000000000..e4395f443 --- /dev/null +++ b/docs/ai-design/2026-03-30-dapi-discovery-button/wireframe.html @@ -0,0 +1,515 @@ + + + + + +DAPI Discovery Button - Wireframe + + + +

DAPI Node Discovery Button

+

Network Settings Screen - Wireframe

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

+ You currently have 8 addresses configured. You can review and edit the new list before saving. +
+
+ + +
+
+
+ + + + diff --git a/src/dapi_discovery.rs b/src/backend_task/dapi_discovery.rs similarity index 90% rename from src/dapi_discovery.rs rename to src/backend_task/dapi_discovery.rs index 99e547649..59b572fe0 100644 --- a/src/dapi_discovery.rs +++ b/src/backend_task/dapi_discovery.rs @@ -113,3 +113,17 @@ pub async fn try_discover_nodes( Ok(valid_urls) } + +/// Discover DAPI nodes and return them as a comma-separated string +/// suitable for storing in `NetworkConfig::dapi_addresses`. +/// +/// Returns `(count, csv)` on success. +pub async fn discover_and_format( + network: Network, + devnet_name: Option<&str>, +) -> Result<(usize, String), DapiDiscoveryError> { + let urls = try_discover_nodes(network, devnet_name).await?; + let count = urls.len(); + let csv = urls.join(","); + Ok((count, csv)) +} diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index 42df3946b..b27416caa 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -56,7 +56,7 @@ pub enum TaskError { /// DAPI node discovery or address resolution failed. #[error(transparent)] - DapiDiscovery(#[from] crate::dapi_discovery::DapiDiscoveryError), + DapiDiscovery(#[from] crate::backend_task::dapi_discovery::DapiDiscoveryError), /// Core wallet not configured for this wallet on a multi-wallet Core node. #[error( diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index efc4c4262..7d16293ce 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -47,6 +47,7 @@ pub mod broadcast_state_transition; pub mod contested_names; pub mod contract; pub mod core; +pub mod dapi_discovery; pub mod dashpay; pub mod document; pub mod error; @@ -423,23 +424,20 @@ impl AppContext { Ok(self.run_shielded_task(shielded_task).await?) } BackendTask::DiscoverDapiNodes { network } => { - let devnet_name = { - self.config - .read() - .map_err(|_| TaskError::LockPoisoned { - resource: "NetworkConfig", - })? - .devnet_name - .clone() - }; - let addr_strings = - crate::dapi_discovery::try_discover_nodes(network, devnet_name.as_deref()) - .await - .map_err(TaskError::from)?; + let devnet_name = self + .config + .read() + .map_err(|_| TaskError::LockPoisoned { + resource: "NetworkConfig", + })? + .devnet_name + .clone(); + let (count, addresses_csv) = + dapi_discovery::discover_and_format(network, devnet_name.as_deref()).await?; Ok(BackendTaskSuccessResult::DapiNodesDiscovered { network, - count: addr_strings.len(), - addresses_csv: addr_strings.join(","), + count, + addresses_csv, }) } BackendTask::None => Ok(BackendTaskSuccessResult::None), diff --git a/src/context/mod.rs b/src/context/mod.rs index 56b3fcee2..fe0a85596 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -578,12 +578,14 @@ impl AppContext { // 3. Parse DAPI addresses from config and rebuild the SDK let address_list = match &cfg.dapi_addresses { Some(addrs) if !addrs.trim().is_empty() => AddressList::from_str(addrs.trim()) - .map_err( - |source| crate::dapi_discovery::DapiDiscoveryError::InvalidAddresses { source }, - )?, + .map_err(|source| { + crate::backend_task::dapi_discovery::DapiDiscoveryError::InvalidAddresses { + source, + } + })?, _ => { return Err( - crate::dapi_discovery::DapiDiscoveryError::AddressesRequired { + crate::backend_task::dapi_discovery::DapiDiscoveryError::AddressesRequired { network: self.network, } .into(), diff --git a/src/lib.rs b/src/lib.rs index afa143d84..9ba4b4ec2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,7 +8,6 @@ pub mod context; pub mod context_provider; pub mod context_provider_spv; pub mod cpu_compatibility; -pub mod dapi_discovery; pub mod database; pub mod logging; #[cfg(any(feature = "mcp", feature = "cli"))] From 494053bcfacb9d6a6cf2fcc824fabeee6ec02ed8 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:03:18 +0200 Subject: [PATCH 27/48] chore: update dapi addresses --- .env.example | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index ffd42fea0..96b68dbdb 100644 --- a/.env.example +++ b/.env.example @@ -2,18 +2,14 @@ # linux: ~/.config/dash-evo-tool/.env # Mac: ~/Library/ApplicationSupport/Dash-Evo-Tool/.env -# DAPI addresses are resolved automatically for mainnet. -# Uncomment and set to override with specific nodes: -# MAINNET_dapi_addresses=https://node1:443,https://node2:443 +MAINNET_dapi_addresses=https://45.135.180.114/,https://194.163.159.171/,https://84.247.180.190/,https://173.212.251.130/,https://75.119.153.10/,https://89.125.209.106/,https://216.238.75.46/,https://5.75.133.148/,https://185.239.208.110/,https://135.181.110.216/,https://157.173.122.25/,https://45.94.58.58/,https://109.199.119.192/,https://31.220.77.84/,https://75.119.137.66/,https://89.35.131.218/,https://167.114.153.110/,https://5.189.145.80/,https://143.198.145.184/,https://195.26.254.228/,https://161.97.83.102/,https://167.88.169.16/,https://139.180.143.115/,https://45.135.180.70/,https://37.60.254.205/,https://89.35.131.39/,https://37.60.235.89/,https://185.202.236.58/,https://85.190.243.3/,https://84.247.180.201/,https://54.69.95.118/,https://161.97.91.217/,https://37.60.235.205/,https://157.173.122.26/,https://161.97.75.36/,https://95.111.233.139/,https://192.248.175.198/,https://172.238.7.25/,https://89.23.117.144/,https://185.209.230.13/,https://155.133.26.122/,https://161.97.179.214/,https://37.60.235.224/,https://103.214.68.30/,https://161.97.88.199/,https://185.119.57.243/,https://77.42.74.68/,https://65.108.246.145/,https://45.153.70.126/,https://82.208.20.153/,https://89.23.112.100/,https://132.243.197.251/,https://132.243.197.250/,https://207.244.247.40/,https://92.63.176.202/,https://109.123.244.131/,https://83.222.9.253/,https://45.76.141.74/,https://38.242.206.56/,https://62.171.144.192/,https://43.167.239.145/,https://46.62.250.247/,https://213.199.54.35/,https://164.68.118.37/,https://62.171.138.186/,https://109.199.124.30/,https://5.189.151.7/,https://91.198.108.37/,https://139.84.137.143/,https://95.111.241.20/,https://158.220.101.28/,https://144.91.87.82/,https://206.245.167.74/,https://192.248.178.237/,https://157.90.238.161/,https://207.180.233.6/,https://77.221.148.204/,https://134.255.182.185/,https://80.240.19.200/,https://37.60.235.24/,https://188.245.90.255/,https://185.215.164.186/,https://185.166.217.154/,https://84.247.180.198/,https://161.97.104.37/,https://178.253.42.64/,https://52.10.213.198/,https://37.60.235.39/,https://49.13.28.255/,https://92.53.120.89/,https://51.158.169.237/,https://188.225.39.14/,https://139.84.231.221/,https://156.67.29.45/,https://75.119.156.254/,https://147.45.183.128/,https://75.119.128.71/,https://37.60.244.253/,https://139.99.201.103/,https://161.97.117.125/,https://52.33.9.172/,https://207.180.224.96/,https://193.70.43.172/,https://147.45.103.99/,https://37.60.235.218/,https://207.180.241.242/,https://84.247.180.205/,https://202.71.14.79/,https://31.220.91.60/,https://37.60.234.205/,https://213.199.53.161/,https://37.60.244.87/,https://38.242.197.189/,https://95.216.146.18/,https://213.171.12.108/,https://75.119.138.9/,https://193.168.3.82/,https://161.97.153.122/,https://37.27.83.17/,https://43.133.171.101/,https://178.18.254.136/,https://84.247.180.200/,https://161.97.180.182/,https://93.115.172.39/,https://49.12.102.105/,https://49.13.237.193/,https://23.88.63.58/,https://195.201.238.55/,https://213.159.77.221/,https://132.243.197.252/,https://161.97.106.14/,https://82.97.240.124/,https://161.97.159.172/,https://185.198.234.17/,https://167.86.77.58/,https://109.199.97.144/,https://37.60.244.35/,https://132.243.197.18/,https://173.212.196.214/,https://217.25.89.156/,https://85.92.111.111/,https://173.249.21.12/,https://114.132.172.215/,https://37.60.254.211/,https://89.125.209.133/,https://168.119.102.10/,https://45.135.180.130/,https://37.60.254.213/,https://217.76.54.175/,https://176.57.213.170/,https://109.199.120.79/,https://81.200.152.144/,https://161.97.85.182/,https://31.220.91.43/,https://108.61.165.170/,https://45.77.129.235/,https://149.102.140.101/,https://158.247.208.247/,https://37.60.236.41/,https://45.85.147.192/,https://89.35.131.149/,https://37.60.234.168/,https://213.199.54.171/,https://91.222.237.98/,https://57.131.28.197/,https://38.242.218.26/,https://89.125.209.195/,https://93.115.172.37/,https://5.189.164.253/,https://91.198.108.36/,https://161.97.175.233/,https://161.97.102.156/,https://89.125.209.110/,https://95.179.241.182/,https://207.180.218.245/,https://157.173.122.20/,https://161.97.88.219/,https://213.199.54.37/,https://31.220.84.93/,https://89.125.209.27/,https://161.97.106.164/,https://95.179.159.65/,https://72.60.38.160/,https://38.242.145.178/,https://51.195.47.118/,https://84.247.187.76/,https://89.35.131.23/,https://52.36.102.91/,https://185.215.166.126/,https://104.156.154.179/,https://173.212.232.90/,https://185.215.164.84/,https://167.86.94.138/,https://38.242.206.103/,https://161.97.91.68/,https://43.167.244.109/,https://87.228.24.64/,https://89.125.50.206/,https://136.244.99.17/,https://161.97.96.120/,https://103.214.68.131/,https://213.199.54.34/,https://109.199.124.135/,https://37.60.254.201/,https://173.199.71.83/,https://44.240.99.214/,https://132.243.197.40/,https://62.171.170.14/,https://45.76.36.241/,https://161.97.142.210/,https://85.193.90.107/,https://45.77.11.194/,https://91.198.108.35/,https://132.243.197.249/,https://157.173.122.21/,https://193.168.3.112/,https://173.212.245.118/,https://216.238.99.9/,https://147.45.236.121/,https://37.60.236.230/,https://89.125.209.152/,https://38.242.200.227/,https://89.125.209.234/,https://144.91.106.188/,https://51.195.235.166/,https://185.252.234.238/,https://158.247.247.241/,https://64.176.10.71/,https://207.180.213.141/,https://66.245.196.52/,https://144.126.141.62/,https://45.135.180.79/,https://194.163.166.76/,https://139.84.236.208/,https://37.60.254.202/,https://172.236.244.81/,https://66.29.147.83/,https://70.34.206.123/,https://77.232.129.86/,https://95.111.239.54/,https://167.235.102.194/,https://207.180.233.246/,https://89.125.50.14/,https://37.60.246.228/,https://139.84.232.129/,https://72.62.58.108/,https://89.125.209.156/,https://161.97.180.105/,https://132.243.197.16/,https://62.171.170.222/,https://149.28.223.171/,https://162.212.35.100/,https://185.239.209.6/,https://161.97.163.75/,https://213.199.44.112/,https://89.223.120.216/,https://89.125.209.120/,https://206.245.167.76/,https://92.255.76.59/,https://37.60.243.59/,https://89.125.209.178/,https://93.115.172.38/,https://194.163.156.190/,https://93.115.172.36/,https://89.35.131.219/,https://91.198.108.38/,https://89.125.209.69/,https://139.84.170.10/,https://45.32.70.131/,https://161.97.74.173/,https://62.171.136.245/,https://178.18.244.255/,https://89.35.131.158/,https://134.255.182.186/,https://158.220.101.10/,https://167.86.93.21/,https://49.13.193.251/,https://164.68.114.36/,https://161.97.66.31/,https://75.119.152.219/,https://64.176.35.235/,https://78.141.225.100/ MAINNET_core_host=127.0.0.1 MAINNET_core_rpc_port=9998 MAINNET_core_rpc_user=dashrpc MAINNET_core_rpc_password=password MAINNET_core_zmq_endpoint=tcp://127.0.0.1:23708 -# DAPI addresses are resolved automatically for testnet. -# Uncomment and set to override with specific nodes: -# TESTNET_dapi_addresses=https://node1:1443,https://node2:1443 +TESTNET_dapi_addresses=https://68.67.122.1:1443,https://68.67.122.2:1443,https://68.67.122.3:1443,https://68.67.122.4:1443,https://68.67.122.5:1443,https://68.67.122.6:1443,https://68.67.122.7:1443,https://68.67.122.8:1443,https://68.67.122.9:1443,https://68.67.122.10:1443,https://68.67.122.11:1443,https://68.67.122.12:1443,https://68.67.122.13:1443,https://68.67.122.14:1443,https://68.67.122.15:1443,https://68.67.122.16:1443,https://68.67.122.17:1443,https://68.67.122.18:1443,https://68.67.122.19:1443,https://68.67.122.20:1443,https://68.67.122.21:1443,https://68.67.122.22:1443,https://68.67.122.23:1443,https://68.67.122.24:1443,https://68.67.122.25:1443,https://68.67.122.26:1443,https://68.67.122.27:1443,https://68.67.122.28:1443,https://68.67.122.29:1443 TESTNET_core_host=127.0.0.1 TESTNET_core_rpc_port=19998 TESTNET_core_rpc_user=dashrpc From 39a20117be29d1de5b8ad27ca988033c6f397a00 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:04:09 +0200 Subject: [PATCH 28/48] chore: fmt --- src/ui/network_chooser_screen.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index f48752fa9..593132007 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -776,7 +776,7 @@ impl NetworkChooserScreen { if zmq_connected { "Connected" } else { "Disconnected" }; ui.colored_label(zmq_color, zmq_label); } - }); + }); } if current_backend_mode == CoreBackendMode::Spv { From 137b987b2e7f2b65a5280089ca54b2003af88e4a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:16:24 +0200 Subject: [PATCH 29/48] fix(review): address PR #767 Copilot review comments - Fix stale doc comment on dapi_addresses (no longer says auto-discovery) - Fix display_message resetting discovery_in_progress on unrelated banners - Add network guard in try_discover_nodes (reject non-Mainnet/Testnet) Co-Authored-By: Claude Opus 4.6 --- src/backend_task/dapi_discovery.rs | 6 ++++++ src/config.rs | 7 +++---- src/ui/network_chooser_screen.rs | 8 ++++++-- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/backend_task/dapi_discovery.rs b/src/backend_task/dapi_discovery.rs index 59b572fe0..6b1983f0f 100644 --- a/src/backend_task/dapi_discovery.rs +++ b/src/backend_task/dapi_discovery.rs @@ -78,6 +78,12 @@ pub async fn try_discover_nodes( network: Network, devnet_name: Option<&str>, ) -> Result, DapiDiscoveryError> { + // Only Mainnet/Testnet have DCG discovery endpoints + match network { + Network::Mainnet | Network::Testnet => {} + _ => return Err(DapiDiscoveryError::AddressesRequired { network }), + } + let provider = TrustedHttpContextProvider::new( network, devnet_name.map(|s| s.to_string()), diff --git a/src/config.rs b/src/config.rs index 25cdaf138..03426d07c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -41,10 +41,9 @@ pub enum ConfigError { #[derive(Debug, Default, Deserialize, Clone)] #[serde(default)] pub struct NetworkConfig { - /// Hostname of Dash Platform node to connect to. - /// `None` or empty means dynamic discovery will be used. - /// Dynamic discovery is currently supported for Mainnet and Testnet only. - /// Devnet and Regtest require explicit addresses. + /// Comma-separated DAPI node URLs (e.g., `https://host:443,https://host2:443`). + /// Required for the app to connect. Use "Refresh DAPI endpoints" in Network + /// Settings to fetch addresses for Mainnet/Testnet. pub dapi_addresses: Option, /// Host of the Dash Core RPC interface (only needed in RPC mode) pub core_host: Option, diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index 593132007..1047f2983 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -2228,8 +2228,12 @@ impl ScreenLike for NetworkChooserScreen { action } - fn display_message(&mut self, _message: &str, _message_type: MessageType) { - self.discovery_in_progress = false; + 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) { From 08a9961f2f5c15e8c9adea9430d4caaf2655e781 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:17:24 +0200 Subject: [PATCH 30/48] chore: minor fix in the msg --- src/ui/network_chooser_screen.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index 593132007..dd325d7c4 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -853,9 +853,8 @@ impl NetworkChooserScreen { .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) .show(ui.ctx(), |ui| { ui.label(format!( - "This will fetch a fresh list of DAPI nodes from a centralized \ - server managed by Dash Core Group, replacing your current {} \ - configured addresses.", + "This will fetch a fresh list of DAPI nodes, replacing your current {} \ + configured addresses in the config file.", dapi_total )); ui.add_space(12.0); From 58248509a11332c1c46075f84423b7bf3baeea43 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:39:43 +0200 Subject: [PATCH 31/48] feat(mcp): add network_refresh_endpoints and network_reinit_sdk tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new MCP tools for network management: - network_refresh_endpoints: fetches DAPI node addresses from DCG discovery service, saves to config, and reinitializes the SDK. Equivalent to the "Refresh DAPI endpoints" button in Network Settings. - network_reinit_sdk: rebuilds Core RPC client and Platform SDK with current config. Use after changing RPC credentials or DAPI addresses. Both require a network parameter matching the active network. network_switch is not exposed via MCP — it requires AppState access that MCP tools don't have (context swapping at the app level). Co-Authored-By: Claude Opus 4.6 --- docs/MCP.md | 2 + src/mcp/server.rs | 2 + src/mcp/tools/network.rs | 184 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 187 insertions(+), 1 deletion(-) diff --git a/docs/MCP.md b/docs/MCP.md index 939da36dd..4aa6a5e2d 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_refresh_endpoints` | `network` | `det-cli network-refresh-endpoints` | Fetch fresh DAPI node addresses from DCG discovery service, save to config, and reinit SDK | +| `network_reinit_sdk` | `network` | `det-cli network-reinit-sdk` | Rebuild Core RPC client and Platform SDK with current config (use after changing credentials) | | `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 | diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 3e1f528fd..020f42834 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -74,6 +74,8 @@ impl DashMcpService { 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::() diff --git a/src/mcp/tools/network.rs b/src/mcp/tools/network.rs index b26a1fa2b..e98092ef5 100644 --- a/src/mcp/tools/network.rs +++ b/src/mcp/tools/network.rs @@ -5,9 +5,12 @@ 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; @@ -67,3 +70,182 @@ impl AsyncTool for NetworkTool { Ok(NetworkOutput { active, available }) } } + +// --------------------------------------------------------------------------- +// NetworkRefreshEndpoints +// --------------------------------------------------------------------------- + +/// Fetch fresh DAPI node addresses from the DCG discovery service and save +/// them to the network configuration. Equivalent to the "Refresh DAPI +/// endpoints" button in Network Settings. +pub struct NetworkRefreshEndpoints; + +#[derive(Debug, Deserialize, schemars::JsonSchema, Default)] +pub struct RefreshEndpointsParams { + /// Target network. Required — must match the server's active network. + pub network: String, +} + +#[derive(Serialize, schemars::JsonSchema)] +pub struct RefreshEndpointsOutput { + /// Number of node addresses discovered. + count: usize, + /// Comma-separated list of DAPI node URLs saved to config. + addresses_csv: String, +} + +impl ToolBase for NetworkRefreshEndpoints { + type Parameter = RefreshEndpointsParams; + type Output = RefreshEndpointsOutput; + type Error = McpToolError; + + fn name() -> Cow<'static, str> { + "network_refresh_endpoints".into() + } + + fn description() -> Option> { + Some( + "Fetch fresh DAPI node addresses from the Dash Core Group discovery service \ + and save them to the network configuration. The SDK is reinitialized with \ + the new addresses." + .into(), + ) + } + + fn annotations() -> Option { + Some( + ToolAnnotations::default() + .read_only(false) + .destructive(false) + .idempotent(true) + .open_world(true), + ) + } +} + +impl AsyncTool for NetworkRefreshEndpoints { + async fn invoke( + service: &DashMcpService, + param: RefreshEndpointsParams, + ) -> Result { + let ctx = service + .ctx() + .await + .map_err(|e| McpToolError::Internal(e.to_string()))?; + resolve::require_network(&ctx, Some(¶m.network))?; + + let network = ctx.network(); + let task = BackendTask::DiscoverDapiNodes { network }; + let result = dispatch_task(&ctx, task) + .await + .map_err(McpToolError::TaskFailed)?; + + match result { + BackendTaskSuccessResult::DapiNodesDiscovered { + count, + addresses_csv, + .. + } => { + // Save to config and reinit SDK + let data_dir = ctx.data_dir(); + if let Ok(mut config) = crate::config::Config::load_from(data_dir) + && let Some(mut network_cfg) = config.config_for_network(network).clone() + { + network_cfg.dapi_addresses = Some(addresses_csv.clone()); + config.update_config_for_network(network, network_cfg.clone()); + let _ = config.save(data_dir); + + if let Ok(mut cfg_lock) = ctx.config.write() { + *cfg_lock = network_cfg; + } + if let Err(e) = std::sync::Arc::clone(&ctx).reinit_core_client_and_sdk() { + tracing::warn!("SDK reinit after endpoint refresh failed: {e}"); + } + } + + Ok(RefreshEndpointsOutput { + count, + addresses_csv, + }) + } + other => Err(McpToolError::Internal(format!( + "Unexpected task result: {other:?}" + ))), + } + } +} + +// --------------------------------------------------------------------------- +// 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:?}" + ))), + } + } +} From 444b7c175b4262854d81403d1fada988bc820ea4 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:50:44 +0200 Subject: [PATCH 32/48] feat(mcp): add network_switch tool, simplify SwitchNetwork flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SwitchNetwork is now a regular BackendTask handled by run_backend_task() instead of being intercepted by AppState. Both GUI and MCP use the same path: dispatch BackendTask::SwitchNetwork → get NetworkContextCreated → handle the result. - Remove LazyContextParams — no longer needed, SwitchNetwork reuses the current AppContext's shared resources (db, subtasks, etc.) - Remove AppState interception of SwitchNetwork in handle_backend_task - Implement SwitchNetwork handler in run_backend_task (creates new AppContext for target network) - Add network_switch MCP tool with swap_context on DashMcpService - Add parse_network helper for name → Network enum conversion Co-Authored-By: Claude Opus 4.6 --- docs/MCP.md | 1 + src/app.rs | 70 ++------------------------- src/backend_task/mod.rs | 19 +++++++- src/mcp/server.rs | 15 ++++++ src/mcp/tools/network.rs | 102 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 139 insertions(+), 68 deletions(-) diff --git a/docs/MCP.md b/docs/MCP.md index 4aa6a5e2d..60d35f3f0 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -70,6 +70,7 @@ Set these in the app's `.env` file (see `.env.example`) or as environment variab | `network_info` | — | `det-cli network-info` | Show active network and available configured networks | | `network_refresh_endpoints` | `network` | `det-cli network-refresh-endpoints` | Fetch fresh DAPI node addresses from DCG discovery service, save to config, and reinit SDK | | `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 | diff --git a/src/app.rs b/src/app.rs index cfd721c61..f9663497a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -11,7 +11,6 @@ use crate::context::connection_status::{ConnectionStatus, OverallConnectionState use crate::database::Database; #[cfg(not(feature = "testing"))] use crate::logging::initialize_logger; -use crate::model::password_info::PasswordInfo; use crate::model::settings::Settings; use crate::spv::CoreBackendMode; use crate::ui::components::{BannerHandle, MessageBanner}; @@ -66,18 +65,6 @@ impl From> for TaskResult { } } -/// Parameters needed for lazy `AppContext` creation when the user switches -/// to a network whose context was deferred at startup. -#[derive(Clone)] -struct LazyContextParams { - data_dir: PathBuf, - db: Arc, - password_info: Option, - subtasks: Arc, - connection_status: Arc, - egui_ctx: egui::Context, -} - struct ThemeState { preference: ThemeMode, resolved: ThemeMode, @@ -146,8 +133,6 @@ pub struct AppState { pub chosen_network: Network, pub connection_status: Arc, pub network_contexts: BTreeMap>, - /// Params kept for lazy AppContext creation when switching networks. - lazy_ctx_params: Option, /// Network whose context is being created asynchronously. While `Some`, /// the UI shows a progress banner and ignores further switch requests. network_switch_pending: Option, @@ -366,16 +351,6 @@ impl AppState { let chosen_network = *network_contexts.keys().next().unwrap(); let active_context = network_contexts.get(&chosen_network).unwrap().clone(); - // Store params for lazy context creation when switching networks later. - let lazy_ctx_params = Some(LazyContextParams { - data_dir, - db: db.clone(), - password_info, - subtasks: subtasks.clone(), - connection_status: connection_status.clone(), - egui_ctx: ctx.clone(), - }); - // load fonts ctx.set_fonts(crate::bundled::fonts().expect("failed to load fonts")); @@ -582,7 +557,6 @@ impl AppState { chosen_network, connection_status, network_contexts, - lazy_ctx_params, network_switch_pending: None, zmq_listeners, core_message_sender, @@ -673,12 +647,6 @@ 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(&mut self, task: BackendTask) { - // Intercept SwitchNetwork — it creates a new context rather than using the current one. - if let BackendTask::SwitchNetwork(network) = task { - self.change_network(network); - return; - } - let sender = self.task_result_sender.clone(); let app_context = self.current_app_context().clone(); let handle = tokio::runtime::Handle::current(); @@ -778,41 +746,11 @@ impl AppState { return; } - // Slow path: create the context on a background thread. - let Some(params) = self.lazy_ctx_params.clone() else { - tracing::error!( - ?network, - "Cannot switch network: lazy context params not available" - ); - return; - }; - + // Slow path: dispatch SwitchNetwork as a backend task. The result + // (NetworkContextCreated) comes back through the task result channel + // and is handled in update(). Same path used by MCP tools. self.network_switch_pending = Some(network); - let sender = self.task_result_sender.clone(); - - tokio::task::spawn_blocking(move || { - let result = AppContext::new( - params.data_dir, - network, - params.db, - params.password_info, - params.subtasks, - params.connection_status, - params.egui_ctx, - ); - let task_result = match result { - Some(ctx) => { - TaskResult::Success(Box::new(BackendTaskSuccessResult::NetworkContextCreated { - network, - context: ctx, - })) - } - None => TaskResult::Error(TaskError::NetworkContextCreationFailed { network }), - }; - if let Err(e) = sender.try_send(task_result) { - tracing::error!("Failed to send network context creation result: {}", e); - } - }); + self.handle_backend_task(BackendTask::SwitchNetwork(network)); } /// Complete the network switch after the context is available. diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index 5bcc04735..72a6fc14c 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -448,8 +448,23 @@ impl AppContext { Arc::clone(self).reinit_core_client_and_sdk()?; Ok(BackendTaskSuccessResult::CoreClientReinitialized) } - BackendTask::SwitchNetwork(_) => { - unreachable!("SwitchNetwork is intercepted by AppState::handle_backend_task") + BackendTask::SwitchNetwork(network) => { + // Create a new AppContext for the target network, reusing shared + // resources (db, subtasks, connection_status) from the current context. + let new_ctx = AppContext::new( + self.data_dir.clone(), + network, + self.db.clone(), + self.password_info.clone(), + self.subtasks.clone(), + self.connection_status.clone(), + self.egui_ctx().clone(), + ) + .ok_or(TaskError::NetworkContextCreationFailed { network })?; + Ok(BackendTaskSuccessResult::NetworkContextCreated { + network, + context: new_ctx, + }) } BackendTask::DiscoverDapiNodes { network } => { let devnet_name = self diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 020f42834..e327cacb1 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -70,12 +70,27 @@ impl DashMcpService { } } + /// Replace the active context. Used by `network_switch` to point the + /// MCP server at a newly created network context. + #[allow(unused_variables)] + pub(crate) fn swap_context(&self, new_ctx: Arc) { + match &self.ctx_provider { + #[cfg(feature = "mcp")] + ContextProvider::Shared(swap) => swap.store(new_ctx), + #[cfg(feature = "cli")] + ContextProvider::Lazy(_) => { + tracing::warn!("Network switch not supported in stdio/CLI mode"); + } + } + } + /// 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::() .with_async_tool::() diff --git a/src/mcp/tools/network.rs b/src/mcp/tools/network.rs index e98092ef5..05a23def5 100644 --- a/src/mcp/tools/network.rs +++ b/src/mcp/tools/network.rs @@ -13,6 +13,7 @@ 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 @@ -249,3 +250,104 @@ impl AsyncTool for NetworkReinitSdk { } } } + +// --------------------------------------------------------------------------- +// 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, +} + +impl ToolBase for NetworkSwitch { + type Parameter = NetworkSwitchParams; + type Output = NetworkSwitchOutput; + type Error = McpToolError; + + fn name() -> Cow<'static, str> { + "network_switch".into() + } + + fn description() -> Option> { + Some( + "Switch the active network. Creates the context if needed (may take \ + a few seconds). Requires that the target network has DAPI addresses \ + configured." + .into(), + ) + } + + fn annotations() -> Option { + Some( + ToolAnnotations::default() + .read_only(false) + .destructive(false) + .idempotent(true) + .open_world(true), + ) + } +} + +/// Parse a network name string into a `Network` enum value. +fn parse_network(name: &str) -> Result { + match name.to_lowercase().as_str() { + "mainnet" | "main" => Ok(Network::Mainnet), + "testnet" | "test" => Ok(Network::Testnet), + "devnet" | "dev" => Ok(Network::Devnet), + "regtest" | "local" => Ok(Network::Regtest), + other => Err(McpToolError::InvalidParam { + message: format!("Unknown network '{other}'. Use mainnet, testnet, devnet, or local."), + }), + } +} + +impl AsyncTool for NetworkSwitch { + async fn invoke( + service: &DashMcpService, + param: NetworkSwitchParams, + ) -> Result { + let target = parse_network(¶m.network)?; + + let ctx = service + .ctx() + .await + .map_err(|e| McpToolError::Internal(e.to_string()))?; + + // Already on the target network — no-op. + if ctx.network() == target { + return Ok(NetworkSwitchOutput { + active: network_display_name(target).to_owned(), + }); + } + + // Dispatch SwitchNetwork through the standard backend task system. + let task = BackendTask::SwitchNetwork(target); + let result = dispatch_task(&ctx, task) + .await + .map_err(McpToolError::TaskFailed)?; + + match result { + BackendTaskSuccessResult::NetworkContextCreated { context, .. } => { + service.swap_context(context); + Ok(NetworkSwitchOutput { + active: network_display_name(target).to_owned(), + }) + } + other => Err(McpToolError::Internal(format!( + "Unexpected task result: {other:?}" + ))), + } + } +} From 259236cc888846a4226e0f0e5ba51684c1992c68 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:54:54 +0200 Subject: [PATCH 33/48] chore(mcp): remove network_refresh_endpoints tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not intended for external exposure — DAPI endpoint refresh is a GUI-only action via the "Refresh DAPI endpoints" button. Co-Authored-By: Claude Opus 4.6 --- docs/MCP.md | 1 - src/mcp/server.rs | 1 - src/mcp/tools/network.rs | 104 --------------------------------------- 3 files changed, 106 deletions(-) diff --git a/docs/MCP.md b/docs/MCP.md index 60d35f3f0..7053ecd31 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -68,7 +68,6 @@ 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_refresh_endpoints` | `network` | `det-cli network-refresh-endpoints` | Fetch fresh DAPI node addresses from DCG discovery service, save to config, and reinit SDK | | `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) | diff --git a/src/mcp/server.rs b/src/mcp/server.rs index e327cacb1..a506f1454 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -88,7 +88,6 @@ impl DashMcpService { pub fn tool_router() -> ToolRouter { ToolRouter::new() .with_async_tool::() - .with_async_tool::() .with_async_tool::() .with_async_tool::() .with_async_tool::() diff --git a/src/mcp/tools/network.rs b/src/mcp/tools/network.rs index 05a23def5..60c39125c 100644 --- a/src/mcp/tools/network.rs +++ b/src/mcp/tools/network.rs @@ -72,110 +72,6 @@ impl AsyncTool for NetworkTool { } } -// --------------------------------------------------------------------------- -// NetworkRefreshEndpoints -// --------------------------------------------------------------------------- - -/// Fetch fresh DAPI node addresses from the DCG discovery service and save -/// them to the network configuration. Equivalent to the "Refresh DAPI -/// endpoints" button in Network Settings. -pub struct NetworkRefreshEndpoints; - -#[derive(Debug, Deserialize, schemars::JsonSchema, Default)] -pub struct RefreshEndpointsParams { - /// Target network. Required — must match the server's active network. - pub network: String, -} - -#[derive(Serialize, schemars::JsonSchema)] -pub struct RefreshEndpointsOutput { - /// Number of node addresses discovered. - count: usize, - /// Comma-separated list of DAPI node URLs saved to config. - addresses_csv: String, -} - -impl ToolBase for NetworkRefreshEndpoints { - type Parameter = RefreshEndpointsParams; - type Output = RefreshEndpointsOutput; - type Error = McpToolError; - - fn name() -> Cow<'static, str> { - "network_refresh_endpoints".into() - } - - fn description() -> Option> { - Some( - "Fetch fresh DAPI node addresses from the Dash Core Group discovery service \ - and save them to the network configuration. The SDK is reinitialized with \ - the new addresses." - .into(), - ) - } - - fn annotations() -> Option { - Some( - ToolAnnotations::default() - .read_only(false) - .destructive(false) - .idempotent(true) - .open_world(true), - ) - } -} - -impl AsyncTool for NetworkRefreshEndpoints { - async fn invoke( - service: &DashMcpService, - param: RefreshEndpointsParams, - ) -> Result { - let ctx = service - .ctx() - .await - .map_err(|e| McpToolError::Internal(e.to_string()))?; - resolve::require_network(&ctx, Some(¶m.network))?; - - let network = ctx.network(); - let task = BackendTask::DiscoverDapiNodes { network }; - let result = dispatch_task(&ctx, task) - .await - .map_err(McpToolError::TaskFailed)?; - - match result { - BackendTaskSuccessResult::DapiNodesDiscovered { - count, - addresses_csv, - .. - } => { - // Save to config and reinit SDK - let data_dir = ctx.data_dir(); - if let Ok(mut config) = crate::config::Config::load_from(data_dir) - && let Some(mut network_cfg) = config.config_for_network(network).clone() - { - network_cfg.dapi_addresses = Some(addresses_csv.clone()); - config.update_config_for_network(network, network_cfg.clone()); - let _ = config.save(data_dir); - - if let Ok(mut cfg_lock) = ctx.config.write() { - *cfg_lock = network_cfg; - } - if let Err(e) = std::sync::Arc::clone(&ctx).reinit_core_client_and_sdk() { - tracing::warn!("SDK reinit after endpoint refresh failed: {e}"); - } - } - - Ok(RefreshEndpointsOutput { - count, - addresses_csv, - }) - } - other => Err(McpToolError::Internal(format!( - "Unexpected task result: {other:?}" - ))), - } - } -} - // --------------------------------------------------------------------------- // NetworkReinitSdk // --------------------------------------------------------------------------- From a7508d0e13741dd71c6a0171b5a6666a2953dafb Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 19:09:55 +0200 Subject: [PATCH 34/48] refactor(mcp): unify context storage so network_switch works in all modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the split ContextProvider enum (ArcSwap for HTTP, OnceCell for CLI) with a unified ContextHolder enum that supports both load() and store(). - HTTP mode: shares the GUI's Arc> directly, so GUI network switches propagate to MCP and vice versa - CLI mode: uses ArcSwapOption with lazy init via OnceCell guard, but swap_context() works after init — enabling network_switch from CLI Co-Authored-By: Claude Opus 4.6 --- src/mcp/server.rs | 92 ++++++++++++++++++++++++++++++----------------- 1 file changed, 59 insertions(+), 33 deletions(-) diff --git a/src/mcp/server.rs b/src/mcp/server.rs index a506f1454..b7019a0da 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -7,24 +7,51 @@ 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 +62,13 @@ impl std::fmt::Debug for DashMcpService { } impl DashMcpService { - /// For HTTP mode: wrap an existing shared context. + /// For HTTP mode: wrap the GUI's shared ArcSwap (same reference). #[cfg(feature = "mcp")] pub fn new_shared(app_context: Arc>) -> Self { Self { - ctx_provider: ContextProvider::Shared(app_context), + ctx: ContextHolder::Shared(app_context), + #[cfg(feature = "cli")] + init_guard: Arc::new(tokio::sync::OnceCell::const_new_with(())), tool_router: Self::tool_router(), } } @@ -48,40 +77,37 @@ impl DashMcpService { #[cfg(feature = "cli")] pub fn new_lazy() -> Self { Self { - ctx_provider: ContextProvider::Lazy(Arc::new(tokio::sync::OnceCell::new())), + ctx: ContextHolder::Standalone(Arc::new(arc_swap::ArcSwapOption::empty())), + init_guard: Arc::new(tokio::sync::OnceCell::new()), tool_router: Self::tool_router(), } } - /// Get the current AppContext. In HTTP mode, loads from ArcSwap. - /// In stdio mode, initializes on first call. + /// Get the current AppContext. /// - /// Each tool must call this exactly once and pass the resulting `Arc` to - /// both validation and the operation to avoid TOCTOU issues with ArcSwap. + /// In HTTP mode, loads from the shared ArcSwap (always initialized). + /// In stdio/CLI mode, initializes on first call, then loads. pub(crate) async fn ctx(&self) -> Result, McpError> { - match &self.ctx_provider { - #[cfg(feature = "mcp")] - ContextProvider::Shared(swap) => Ok(swap.load_full()), - #[cfg(feature = "cli")] - ContextProvider::Lazy(cell) => cell - .get_or_try_init(|| async { init_app_context().await }) - .await - .cloned(), + #[cfg(feature = "cli")] + if let ContextHolder::Standalone(_) = &self.ctx { + let ctx_holder = self.ctx.clone(); + self.init_guard + .get_or_try_init(|| async { + let app_context = init_app_context().await?; + ctx_holder.store(app_context); + Ok::<(), McpError>(()) + }) + .await?; } + self.ctx + .load() + .ok_or_else(|| McpError::internal_error("AppContext not initialized", None)) } /// Replace the active context. Used by `network_switch` to point the - /// MCP server at a newly created network context. - #[allow(unused_variables)] + /// server at a newly created network context. Works in all modes. pub(crate) fn swap_context(&self, new_ctx: Arc) { - match &self.ctx_provider { - #[cfg(feature = "mcp")] - ContextProvider::Shared(swap) => swap.store(new_ctx), - #[cfg(feature = "cli")] - ContextProvider::Lazy(_) => { - tracing::warn!("Network switch not supported in stdio/CLI mode"); - } - } + self.ctx.store(new_ctx); } /// Build the tool router using trait-based tool composition. From 25b9748ee725afabac2f700dc73aaa2b6598f204 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:45:30 +0200 Subject: [PATCH 35/48] fix(mcp): persist network choice in network_switch tool Save the chosen network to the database after switching so subsequent CLI invocations start on the same network. Co-Authored-By: Claude Opus 4.6 --- src/mcp/tools/network.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/mcp/tools/network.rs b/src/mcp/tools/network.rs index 60c39125c..8ca482751 100644 --- a/src/mcp/tools/network.rs +++ b/src/mcp/tools/network.rs @@ -236,6 +236,11 @@ impl AsyncTool for NetworkSwitch { match result { BackendTaskSuccessResult::NetworkContextCreated { context, .. } => { + // Persist the network choice so the next CLI invocation + // starts on the same network. + context + .update_settings(crate::ui::RootScreenType::RootScreenNetworkChooser) + .ok(); service.swap_context(context); Ok(NetworkSwitchOutput { active: network_display_name(target).to_owned(), From 6b8431750848350c4a8149e1a9e4bf399655fad5 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:50:20 +0200 Subject: [PATCH 36/48] refactor: move network persist into BackendTask::SwitchNetwork handler Network choice is now persisted to DB inside the SwitchNetwork backend task handler, so both GUI and MCP get persistence for free. GUI's fast path (context already exists) still persists in finalize_network_switch since it doesn't go through the backend task. Co-Authored-By: Claude Opus 4.6 --- src/app.rs | 3 ++- src/backend_task/mod.rs | 5 +++++ src/mcp/tools/network.rs | 5 ----- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/app.rs b/src/app.rs index f9663497a..22a5be99c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -793,7 +793,8 @@ impl AppState { self.zmq_listeners.insert(network, listener); } - // Persist the network choice. + // Persist the network choice (fast path — slow path persists in the + // BackendTask::SwitchNetwork handler, but fast path skips the handler). app_context .update_settings(RootScreenType::RootScreenNetworkChooser) .ok(); diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index 72a6fc14c..82c4b3189 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -461,6 +461,11 @@ impl AppContext { self.egui_ctx().clone(), ) .ok_or(TaskError::NetworkContextCreationFailed { network })?; + // Persist the network choice so subsequent startups (GUI, CLI, + // MCP) all begin on the same network. + new_ctx + .update_settings(crate::ui::RootScreenType::RootScreenNetworkChooser) + .ok(); Ok(BackendTaskSuccessResult::NetworkContextCreated { network, context: new_ctx, diff --git a/src/mcp/tools/network.rs b/src/mcp/tools/network.rs index 8ca482751..60c39125c 100644 --- a/src/mcp/tools/network.rs +++ b/src/mcp/tools/network.rs @@ -236,11 +236,6 @@ impl AsyncTool for NetworkSwitch { match result { BackendTaskSuccessResult::NetworkContextCreated { context, .. } => { - // Persist the network choice so the next CLI invocation - // starts on the same network. - context - .update_settings(crate::ui::RootScreenType::RootScreenNetworkChooser) - .ok(); service.swap_context(context); Ok(NetworkSwitchOutput { active: network_display_name(target).to_owned(), From 45a7d541d95622723bdaebebdd6cb76a8cd98078 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:14:03 +0200 Subject: [PATCH 37/48] feat(mcp): force SPV backend in headless mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Headless (CLI/MCP) has no Dash Core RPC node — force SPV mode so wallet tools work without a local node. Uses volatile (in-memory only) mode switch to avoid overwriting the GUI's saved RPC preference in the DB. Adds `set_core_backend_mode_volatile()` to AppContext for in-memory-only backend mode changes. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/context/mod.rs | 21 +++++++++++++++++---- src/mcp/server.rs | 8 ++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/context/mod.rs b/src/context/mod.rs index fe0a85596..6231578c8 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -438,13 +438,26 @@ impl AppContext { } pub fn set_core_backend_mode(self: &Arc, mode: CoreBackendMode) { + self.set_core_backend_mode_inner(mode, true); + } + + /// Switch the backend mode in-memory only, without persisting to the DB. + /// Used by headless (MCP/CLI) mode to force SPV without overwriting the + /// GUI's saved preference. + pub fn set_core_backend_mode_volatile(self: &Arc, mode: CoreBackendMode) { + self.set_core_backend_mode_inner(mode, false); + } + + fn set_core_backend_mode_inner(self: &Arc, mode: CoreBackendMode, persist: bool) { self.core_backend_mode .store(mode.as_u8(), Ordering::Relaxed); - // Persist the mode to the database (hold the guard to ensure cache invalidation) - let _guard = self.invalidate_settings_cache(); - if let Err(e) = self.db.update_core_backend_mode(mode.as_u8()) { - tracing::error!("Failed to persist core backend mode: {}", e); + if persist { + // Persist the mode to the database (hold the guard to ensure cache invalidation) + let _guard = self.invalidate_settings_cache(); + if let Err(e) = self.db.update_core_backend_mode(mode.as_u8()) { + tracing::error!("Failed to persist core backend mode: {}", e); + } } // Switch SDK context provider to match the selected backend. diff --git a/src/mcp/server.rs b/src/mcp/server.rs index b7019a0da..8be0c3546 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -2,6 +2,7 @@ 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}; @@ -251,6 +252,13 @@ pub async fn init_app_context() -> Result, McpError> { ) })?; + // Headless mode has no Dash Core RPC — force SPV backend so wallet + // tools work without a local node. + if app_context.core_backend_mode() != CoreBackendMode::Spv { + tracing::info!("Headless mode: forcing SPV backend (was RPC)"); + app_context.set_core_backend_mode_volatile(CoreBackendMode::Spv); + } + if let Err(e) = app_context.start_spv() { tracing::warn!("SPV start failed (wallet tools may not work): {e}"); } else { From 145f9fd244e579f7a94f5003dbe6419b95183dba Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:49:41 +0200 Subject: [PATCH 38/48] feat: add user-friendly messages for token validation errors Handle 5 token-related BasicError consensus errors with dedicated TaskError variants: InvalidTokenNameCharacter, InvalidTokenNameLength, InvalidTokenLanguageCode, TokenDecimalsOverLimit, InvalidTokenBaseSupply. Each variant extracts structured fields from the SDK error and provides a clear "what happened + what to do" message for everyday users. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend_task/error.rs | 372 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 372 insertions(+) diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index cf92aa612..bf0e471d8 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -388,6 +388,66 @@ pub enum TaskError { )] TokenPositionNotFound { position: u16 }, + /// The token name contains whitespace or control characters. + #[error( + "The token name \"{token_name}\" in {form} contains invalid characters. \ + Token names must not include spaces or control characters. Please rename and try again." + )] + InvalidTokenNameCharacter { + form: String, + token_name: String, + #[source] + source_error: Box, + }, + + /// The token name length is outside the allowed range. + #[error( + "The token {form} is {actual} characters long, but must be between {min} and {max}. \ + Please adjust the name length and try again." + )] + InvalidTokenNameLength { + form: String, + actual: usize, + min: usize, + max: usize, + #[source] + source_error: Box, + }, + + /// The token language code is not recognized. + #[error( + "The language code \"{language_code}\" is not valid. \ + Use a standard language code like \"en\" or \"fr\" and try again." + )] + InvalidTokenLanguageCode { + language_code: String, + #[source] + source_error: Box, + }, + + /// The token's decimal places exceed the platform limit. + #[error( + "Token decimals cannot exceed {max_decimals}, but {decimals} was specified. \ + Please use a smaller value." + )] + TokenDecimalsOverLimit { + decimals: u8, + max_decimals: u8, + #[source] + source_error: Box, + }, + + /// The token's base supply exceeds the platform limit. + #[error( + "The token base supply of {base_supply} is too large. \ + Please use a smaller value." + )] + InvalidTokenBaseSupply { + base_supply: u64, + #[source] + source_error: Box, + }, + // ────────────────────────────────────────────────────────────────────────── // Contract errors // ────────────────────────────────────────────────────────────────────────── @@ -1159,6 +1219,26 @@ impl From for TaskError { current_count: u64, minimum_required: u64, }, + InvalidTokenNameCharacter { + form: String, + token_name: String, + }, + InvalidTokenNameLength { + form: String, + actual: usize, + min: usize, + max: usize, + }, + InvalidTokenLanguageCode { + language_code: String, + }, + TokenDecimalsOverLimit { + decimals: u8, + max_decimals: u8, + }, + InvalidTokenBaseSupply { + base_supply: u64, + }, } let kind: Option = { @@ -1204,6 +1284,36 @@ impl From for TaskError { minimum_required: e.minimum_required(), }) } + ConsensusError::BasicError(BasicError::InvalidTokenNameCharacterError(e)) => { + Some(ConsensusKind::InvalidTokenNameCharacter { + form: e.form().to_string(), + token_name: e.token_name().to_string(), + }) + } + ConsensusError::BasicError(BasicError::InvalidTokenNameLengthError(e)) => { + Some(ConsensusKind::InvalidTokenNameLength { + form: e.form().to_string(), + actual: e.actual(), + min: e.min(), + max: e.max(), + }) + } + ConsensusError::BasicError(BasicError::InvalidTokenLanguageCodeError(e)) => { + Some(ConsensusKind::InvalidTokenLanguageCode { + language_code: e.language_code().to_string(), + }) + } + ConsensusError::BasicError(BasicError::DecimalsOverLimitError(e)) => { + Some(ConsensusKind::TokenDecimalsOverLimit { + decimals: e.decimals(), + max_decimals: e.max_decimals(), + }) + } + ConsensusError::BasicError(BasicError::InvalidTokenBaseSupplyError(e)) => { + Some(ConsensusKind::InvalidTokenBaseSupply { + base_supply: e.base_supply(), + }) + } _ => None, }) .or_else(|| { @@ -1262,6 +1372,45 @@ impl From for TaskError { minimum_required, source_error: boxed, }, + Some(ConsensusKind::InvalidTokenNameCharacter { form, token_name }) => { + TaskError::InvalidTokenNameCharacter { + form, + token_name, + source_error: boxed, + } + } + Some(ConsensusKind::InvalidTokenNameLength { + form, + actual, + min, + max, + }) => TaskError::InvalidTokenNameLength { + form, + actual, + min, + max, + source_error: boxed, + }, + Some(ConsensusKind::InvalidTokenLanguageCode { language_code }) => { + TaskError::InvalidTokenLanguageCode { + language_code, + source_error: boxed, + } + } + Some(ConsensusKind::TokenDecimalsOverLimit { + decimals, + max_decimals, + }) => TaskError::TokenDecimalsOverLimit { + decimals, + max_decimals, + source_error: boxed, + }, + Some(ConsensusKind::InvalidTokenBaseSupply { base_supply }) => { + TaskError::InvalidTokenBaseSupply { + base_supply, + source_error: boxed, + } + } None => { // Extract timeout duration before consuming boxed. let timeout_secs = if let SdkError::TimeoutReached(d, _) = &*boxed { @@ -1367,6 +1516,10 @@ mod tests { use super::*; use dash_sdk::dapi_client::DapiClientError; use dash_sdk::dapi_client::transport::TransportError; + use dash_sdk::dpp::consensus::basic::data_contract::{ + DecimalsOverLimitError, InvalidTokenBaseSupplyError, InvalidTokenLanguageCodeError, + InvalidTokenNameCharacterError, InvalidTokenNameLengthError, + }; use dash_sdk::dpp::consensus::basic::identity::InvalidInstantAssetLockProofSignatureError; use dash_sdk::dpp::consensus::state::identity::duplicated_identity_public_key_id_state_error::DuplicatedIdentityPublicKeyIdStateError; use dash_sdk::dpp::consensus::state::identity::duplicated_identity_public_key_state_error::DuplicatedIdentityPublicKeyStateError; @@ -2289,4 +2442,223 @@ mod tests { "Connection refused should NOT say 'timed out', got: {msg}" ); } + + // ─── Token validation consensus error tests ────────────────────────────── + + #[test] + fn from_sdk_error_invalid_token_name_character_via_consensus() { + let consensus = ConsensusError::from(InvalidTokenNameCharacterError::new( + "singular form".to_string(), + "token lklimek".to_string(), + )); + let sdk_err = SdkError::from(consensus); + let err = TaskError::from(sdk_err); + assert!(matches!(err, TaskError::InvalidTokenNameCharacter { .. })); + } + + #[test] + fn from_sdk_error_invalid_token_name_character_via_broadcast() { + let consensus = ConsensusError::from(InvalidTokenNameCharacterError::new( + "singular form".to_string(), + "bad name".to_string(), + )); + let broadcast_err = dash_sdk::error::StateTransitionBroadcastError { + code: 10201, + message: "invalid token name character".to_string(), + cause: Some(consensus), + }; + let sdk_err = SdkError::StateTransitionBroadcastError(broadcast_err); + let err = TaskError::from(sdk_err); + assert!(matches!(err, TaskError::InvalidTokenNameCharacter { .. })); + } + + #[test] + fn invalid_token_name_character_display_is_user_friendly() { + let consensus = ConsensusError::from(InvalidTokenNameCharacterError::new( + "singular form".to_string(), + "bad\tname".to_string(), + )); + let sdk_err = SdkError::from(consensus); + let err = TaskError::from(sdk_err); + let msg = err.to_string(); + assert!( + msg.contains("bad\tname"), + "Expected token name in message, got: {msg}" + ); + assert!( + msg.contains("rename"), + "Expected actionable guidance, got: {msg}" + ); + } + + #[test] + fn from_sdk_error_invalid_token_name_length_via_consensus() { + let consensus = + ConsensusError::from(InvalidTokenNameLengthError::new(2, 3, 24, "singular form")); + let sdk_err = SdkError::from(consensus); + let err = TaskError::from(sdk_err); + assert!( + matches!( + err, + TaskError::InvalidTokenNameLength { + actual: 2, + min: 3, + max: 24, + .. + } + ), + "Expected InvalidTokenNameLength, got: {err:?}" + ); + } + + #[test] + fn from_sdk_error_invalid_token_name_length_via_broadcast() { + let consensus = + ConsensusError::from(InvalidTokenNameLengthError::new(50, 3, 24, "singular form")); + let broadcast_err = dash_sdk::error::StateTransitionBroadcastError { + code: 10202, + message: "invalid token name length".to_string(), + cause: Some(consensus), + }; + let sdk_err = SdkError::StateTransitionBroadcastError(broadcast_err); + let err = TaskError::from(sdk_err); + assert!(matches!(err, TaskError::InvalidTokenNameLength { .. })); + } + + #[test] + fn invalid_token_name_length_display_is_user_friendly() { + let consensus = + ConsensusError::from(InvalidTokenNameLengthError::new(2, 3, 24, "singular form")); + let sdk_err = SdkError::from(consensus); + let err = TaskError::from(sdk_err); + let msg = err.to_string(); + assert!(msg.contains("2"), "Expected actual length, got: {msg}"); + assert!(msg.contains("3"), "Expected min length, got: {msg}"); + assert!(msg.contains("24"), "Expected max length, got: {msg}"); + assert!( + msg.contains("adjust"), + "Expected actionable guidance, got: {msg}" + ); + } + + #[test] + fn from_sdk_error_invalid_token_language_code_via_consensus() { + let consensus = + ConsensusError::from(InvalidTokenLanguageCodeError::new("zz_FAKE".to_string())); + let sdk_err = SdkError::from(consensus); + let err = TaskError::from(sdk_err); + assert!(matches!(err, TaskError::InvalidTokenLanguageCode { .. })); + } + + #[test] + fn from_sdk_error_invalid_token_language_code_via_broadcast() { + let consensus = ConsensusError::from(InvalidTokenLanguageCodeError::new("xx".to_string())); + let broadcast_err = dash_sdk::error::StateTransitionBroadcastError { + code: 10203, + message: "invalid language code".to_string(), + cause: Some(consensus), + }; + let sdk_err = SdkError::StateTransitionBroadcastError(broadcast_err); + let err = TaskError::from(sdk_err); + assert!(matches!(err, TaskError::InvalidTokenLanguageCode { .. })); + } + + #[test] + fn invalid_token_language_code_display_is_user_friendly() { + let consensus = + ConsensusError::from(InvalidTokenLanguageCodeError::new("zz_FAKE".to_string())); + let sdk_err = SdkError::from(consensus); + let err = TaskError::from(sdk_err); + let msg = err.to_string(); + assert!( + msg.contains("zz_FAKE"), + "Expected language code in message, got: {msg}" + ); + assert!( + msg.contains("en") || msg.contains("fr"), + "Expected example codes, got: {msg}" + ); + } + + #[test] + fn from_sdk_error_token_decimals_over_limit_via_consensus() { + let consensus = ConsensusError::from(DecimalsOverLimitError::new(20, 8)); + let sdk_err = SdkError::from(consensus); + let err = TaskError::from(sdk_err); + assert!( + matches!( + err, + TaskError::TokenDecimalsOverLimit { + decimals: 20, + max_decimals: 8, + .. + } + ), + "Expected TokenDecimalsOverLimit, got: {err:?}" + ); + } + + #[test] + fn from_sdk_error_token_decimals_over_limit_via_broadcast() { + let consensus = ConsensusError::from(DecimalsOverLimitError::new(20, 8)); + let broadcast_err = dash_sdk::error::StateTransitionBroadcastError { + code: 10204, + message: "decimals over limit".to_string(), + cause: Some(consensus), + }; + let sdk_err = SdkError::StateTransitionBroadcastError(broadcast_err); + let err = TaskError::from(sdk_err); + assert!(matches!(err, TaskError::TokenDecimalsOverLimit { .. })); + } + + #[test] + fn token_decimals_over_limit_display_is_user_friendly() { + let consensus = ConsensusError::from(DecimalsOverLimitError::new(20, 8)); + let sdk_err = SdkError::from(consensus); + let err = TaskError::from(sdk_err); + let msg = err.to_string(); + assert!(msg.contains("20"), "Expected decimals value, got: {msg}"); + assert!(msg.contains("8"), "Expected max decimals, got: {msg}"); + assert!( + msg.contains("smaller value"), + "Expected actionable guidance, got: {msg}" + ); + } + + #[test] + fn from_sdk_error_invalid_token_base_supply_via_consensus() { + let consensus = ConsensusError::from(InvalidTokenBaseSupplyError::new(u64::MAX)); + let sdk_err = SdkError::from(consensus); + let err = TaskError::from(sdk_err); + assert!(matches!(err, TaskError::InvalidTokenBaseSupply { .. })); + } + + #[test] + fn from_sdk_error_invalid_token_base_supply_via_broadcast() { + let consensus = ConsensusError::from(InvalidTokenBaseSupplyError::new(u64::MAX)); + let broadcast_err = dash_sdk::error::StateTransitionBroadcastError { + code: 10205, + message: "invalid base supply".to_string(), + cause: Some(consensus), + }; + let sdk_err = SdkError::StateTransitionBroadcastError(broadcast_err); + let err = TaskError::from(sdk_err); + assert!(matches!(err, TaskError::InvalidTokenBaseSupply { .. })); + } + + #[test] + fn invalid_token_base_supply_display_is_user_friendly() { + let consensus = ConsensusError::from(InvalidTokenBaseSupplyError::new(u64::MAX)); + let sdk_err = SdkError::from(consensus); + let err = TaskError::from(sdk_err); + let msg = err.to_string(); + assert!( + msg.contains(&u64::MAX.to_string()), + "Expected base supply value, got: {msg}" + ); + assert!( + msg.contains("smaller value"), + "Expected actionable guidance, got: {msg}" + ); + } } From 7e9cec85f5b0f4704bb448976f8222f9c0218918 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:29:34 +0200 Subject: [PATCH 39/48] chore: show details to everyone --- src/app.rs | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/app.rs b/src/app.rs index 22a5be99c..3f9a42c26 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1111,15 +1111,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); } From 9957513bcb18e5d7a73e79719577c21a5502235d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:09:50 +0200 Subject: [PATCH 40/48] chore: relabel "import tokens" --- src/ui/tokens/add_token_by_id_screen.rs | 6 +++--- src/ui/tokens/tokens_screen/mod.rs | 2 +- src/ui/tokens/tokens_screen/my_tokens.rs | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) 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(), ) From bc9792a803703af6097327c188bc405faff62478 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:14:39 +0200 Subject: [PATCH 41/48] fix(spv): auto-start SPV on network switch via start_spv flag BackendTask::SwitchNetwork was missing SPV initialization after creating the new network context, leaving MCP/CLI users without wallet functionality (10-minute timeout on ensure_spv_synced). Add `start_spv: bool` field to the SwitchNetwork variant. When true, the handler forces SPV mode (volatile, not persisted) and starts the SPV client on the new context. MCP/CLI always passes true (headless needs SPV). GUI reads the user's auto_start_spv DB setting to respect their preference. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app.rs | 13 +++++++++++-- src/backend_task/mod.rs | 29 +++++++++++++++++++++++++++-- src/mcp/tools/network.rs | 15 +++++++++++++-- 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/src/app.rs b/src/app.rs index 3f9a42c26..20dd770b0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -750,7 +750,12 @@ impl AppState { // (NetworkContextCreated) comes back through the task result channel // and is handled in update(). Same path used by MCP tools. self.network_switch_pending = Some(network); - self.handle_backend_task(BackendTask::SwitchNetwork(network)); + let start_spv = self + .current_app_context() + .db + .get_auto_start_spv() + .unwrap_or(false); + self.handle_backend_task(BackendTask::SwitchNetwork { network, start_spv }); } /// Complete the network switch after the context is available. @@ -1080,7 +1085,11 @@ impl App for AppState { ); self.visible_screen_mut().refresh(); } - BackendTaskSuccessResult::NetworkContextCreated { network, context } => { + BackendTaskSuccessResult::NetworkContextCreated { + network, + context, + .. + } => { self.network_contexts.insert(network, context); self.network_switch_pending = None; self.finalize_network_switch(network); diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index 82c4b3189..faad9984d 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -10,6 +10,7 @@ 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 crate::spv::CoreBackendMode; use dash_sdk::dpp::dashcore::Address; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::dashcore::address::NetworkChecked; @@ -105,7 +106,11 @@ pub enum BackendTask { ReinitCoreClientAndSdk, /// Create a new network context and switch to it. /// Intercepted by `AppState` — never dispatched to `AppContext::run_backend_task`. - SwitchNetwork(Network), + /// When `start_spv` is true, SPV sync is started on the new context automatically. + SwitchNetwork { + network: Network, + start_spv: bool, + }, /// Discover DAPI nodes from the DCG-operated HTTPS service. DiscoverDapiNodes { network: Network, @@ -351,6 +356,7 @@ pub enum BackendTaskSuccessResult { NetworkContextCreated { network: Network, context: Arc, + spv_started: bool, }, /// Fresh DAPI node addresses discovered from the DCG service. @@ -448,7 +454,7 @@ impl AppContext { Arc::clone(self).reinit_core_client_and_sdk()?; Ok(BackendTaskSuccessResult::CoreClientReinitialized) } - BackendTask::SwitchNetwork(network) => { + BackendTask::SwitchNetwork { network, start_spv } => { // Create a new AppContext for the target network, reusing shared // resources (db, subtasks, connection_status) from the current context. let new_ctx = AppContext::new( @@ -466,9 +472,28 @@ impl AppContext { new_ctx .update_settings(crate::ui::RootScreenType::RootScreenNetworkChooser) .ok(); + + let spv_started = if start_spv { + if new_ctx.core_backend_mode() != CoreBackendMode::Spv { + new_ctx.set_core_backend_mode_volatile(CoreBackendMode::Spv); + } + match new_ctx.start_spv() { + Ok(()) => { + tracing::info!(?network, "SPV started after network switch"); + true + } + Err(e) => { + tracing::warn!(?network, "SPV start failed after network switch: {e}"); + false + } + } + } else { + false + }; Ok(BackendTaskSuccessResult::NetworkContextCreated { network, context: new_ctx, + spv_started, }) } BackendTask::DiscoverDapiNodes { network } => { diff --git a/src/mcp/tools/network.rs b/src/mcp/tools/network.rs index 60c39125c..9b4eddc0c 100644 --- a/src/mcp/tools/network.rs +++ b/src/mcp/tools/network.rs @@ -165,6 +165,8 @@ pub struct NetworkSwitchParams { 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 { @@ -225,20 +227,29 @@ impl AsyncTool for NetworkSwitch { if ctx.network() == target { return Ok(NetworkSwitchOutput { active: network_display_name(target).to_owned(), + spv_started: true, }); } // Dispatch SwitchNetwork through the standard backend task system. - let task = BackendTask::SwitchNetwork(target); + 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, .. } => { + 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!( From 86a8154d58b1529a9e676b60c34baacf6e033a6e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:33:12 +0200 Subject: [PATCH 42/48] fix(shielded): init shielded wallets when protocol version arrives from DAPI Shielded wallet init was silently skipped for newly created/unlocked wallets because handle_wallet_unlocked runs before the platform protocol version is fetched from DAPI (still 0). When set_platform_protocol_version later crosses the shielded threshold (>= 12), retroactively initialize any unlocked wallets that were missed. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend_task/platform_info.rs | 2 +- src/context/mod.rs | 17 ++++++++++--- src/context/wallet_lifecycle.rs | 40 +++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 4 deletions(-) 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/context/mod.rs b/src/context/mod.rs index 6231578c8..2ea63ffd9 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -508,9 +508,20 @@ impl AppContext { } /// Update the cached platform protocol version from epoch info. - pub fn set_platform_protocol_version(&self, version: u32) { - self.platform_protocol_version - .store(version, Ordering::Relaxed); + /// + /// When the version crosses the shielded threshold for the first time, + /// retroactively initializes shielded wallets that were unlocked before + /// the protocol version was known. + pub fn set_platform_protocol_version(self: &Arc, version: u32) { + let old = self + .platform_protocol_version + .swap(version, Ordering::Relaxed); + + if old < Self::SHIELDED_MIN_PROTOCOL_VERSION + && version >= Self::SHIELDED_MIN_PROTOCOL_VERSION + { + self.init_missing_shielded_wallets(); + } } /// Minimum protocol version required for shielded (ZK) transactions. diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index de488bacb..339382ae3 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -195,6 +195,46 @@ impl AppContext { self.queue_spv_wallet_unload(seed_hash); } + /// Initialize shielded state for unlocked wallets that were skipped + /// because the protocol version wasn't known at unlock time. + /// Called when the protocol version first crosses the shielded threshold. + pub(crate) fn init_missing_shielded_wallets(self: &Arc) { + // Collect candidate seed hashes while holding locks, then release + // before calling initialize_shielded_wallet (which re-acquires both). + let candidates: Vec = (|| { + let wallets = self.wallets.read().ok()?; + let existing = self.shielded_states.lock().ok()?; + Some( + wallets + .iter() + .filter(|(hash, wallet_arc)| { + !existing.contains_key(*hash) + && wallet_arc.read().ok().map(|w| w.is_open()).unwrap_or(false) + }) + .map(|(hash, _)| *hash) + .collect(), + ) + })() + .unwrap_or_default(); + + for seed_hash in candidates { + match self.initialize_shielded_wallet(seed_hash) { + Ok(_) => { + tracing::info!( + seed = %hex::encode(seed_hash), + "Shielded wallet initialized after protocol version update" + ); + self.queue_shielded_sync(seed_hash); + } + Err(e) => tracing::debug!( + seed = %hex::encode(seed_hash), + error = %e, + "Shielded wallet init failed after protocol version update" + ), + } + } + } + /// Queue async SyncNotes -> CheckNullifiers for an already-initialized /// shielded wallet. Tracked via `subtasks` so it participates in graceful /// shutdown and cancellation. From a4aff7bcecf84efb9dc53321cca8f54503f171fd Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:22:14 +0200 Subject: [PATCH 43/48] fix(spv): fall back to tempdir when SPV data directory is locked When the SPV data directory is locked by a stale lock from a previously crashed process, DiskStorageManager initialization now catches the lock error and retries with a fresh temporary directory. This prevents MCP/CLI tools from hanging forever when ensure_spv_synced() polls an SPV that can never start. The TempDir handle is stored on SpvManager so the OS does not reclaim the directory while SPV is running; it is cleaned up automatically when SpvManager is dropped. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/spv/manager.rs | 54 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/src/spv/manager.rs b/src/spv/manager.rs index e1142a487..76f17f015 100644 --- a/src/spv/manager.rs +++ b/src/spv/manager.rs @@ -174,6 +174,9 @@ pub struct SpvManager { connected_peers: Arc>, // Push SPV status updates to ConnectionStatus (set after construction) connection_status: Mutex>>, + // Holds the tempdir handle when falling back from a locked data directory. + // Kept alive so the OS doesn't reclaim the directory while SPV is running. + _fallback_tempdir: Mutex>, } /// Requests that can be sent to the SPV runtime thread @@ -329,6 +332,7 @@ impl SpvManager { network_manager: Arc::new(AsyncRwLock::new(None)), connected_peers: Arc::new(RwLock::new(0)), connection_status: Mutex::new(None), + _fallback_tempdir: Mutex::new(None), }); Ok(manager) @@ -1446,9 +1450,53 @@ impl SpvManager { *nm_guard = Some(network_manager.clone()); } - let storage_manager = DiskStorageManager::new(&config) - .await - .map_err(|e| format!("Failed to initialize SPV storage: {e}"))?; + let (storage_manager, config) = match DiskStorageManager::new(&config).await { + Ok(sm) => (sm, config), + Err(e) => { + let err_str = e.to_string(); + if err_str.contains("locked") || err_str.contains("already in use") { + tracing::warn!( + original_dir = %self.data_dir.display(), + "SPV data directory is locked by another process, falling back to a temporary directory" + ); + let tmpdir = tempfile::Builder::new() + .prefix("dash-evo-tool-spv-") + .tempdir() + .map_err(|e| format!("Failed to create SPV fallback tempdir: {e}"))?; + let tmpdir_path = tmpdir.path().to_path_buf(); + + // Rebuild config with the tempdir path + let mut fallback_config = ClientConfig::new(self.network) + .with_storage_path(tmpdir_path) + .with_validation_mode(ValidationMode::Full) + .with_start_height(start_height) + .with_mempool_tracking(MempoolStrategy::BloomFilter); + // Re-apply peer configuration (devnet/regtest always need explicit + // peers; mainnet/testnet use them when the user chose local node) + let needs_explicit_peer = + matches!(self.network, Network::Devnet | Network::Regtest) + || self.use_local_node(); + if needs_explicit_peer && let Some(peer) = self.primary_peer_socket() { + fallback_config.add_peer(peer); + } + + let sm = DiskStorageManager::new(&fallback_config) + .await + .map_err(|e| { + format!("Failed to initialize SPV storage in fallback tempdir: {e}") + })?; + + // Keep the TempDir handle alive so the OS doesn't reclaim it + if let Ok(mut guard) = self._fallback_tempdir.lock() { + *guard = Some(tmpdir); + } + + (sm, fallback_config) + } else { + return Err(format!("Failed to initialize SPV storage: {e}")); + } + } + }; DashSpvClient::new( config, From 569c3c46342b1d1e8f7e9bec69e135460750c6a6 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:25:59 +0200 Subject: [PATCH 44/48] fix(mcp): remove unnecessary SPV gate from platform_addresses_list The tool queries DAPI directly for platform address balances and does not need SPV sync. When SPV was unavailable (e.g. stale lock on the data directory), the tool would hang forever waiting for sync. Also clarifies SPV requirements in docs (MCP.md, EXPOSING_BACKEND_TASKS.md) and the ensure_spv_synced() doc comment. Co-Authored-By: Claude Opus 4.6 --- docs/EXPOSING_BACKEND_TASKS.md | 6 ++++-- docs/MCP.md | 4 ++++ src/mcp/resolve.rs | 5 +++++ src/mcp/tools/wallet.rs | 4 +++- 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/docs/EXPOSING_BACKEND_TASKS.md b/docs/EXPOSING_BACKEND_TASKS.md index c41243036..53abd7a28 100644 --- a/docs/EXPOSING_BACKEND_TASKS.md +++ b/docs/EXPOSING_BACKEND_TASKS.md @@ -74,8 +74,10 @@ 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 — ONLY for core-chain tools (see SPV gate rule) resolve::ensure_spv_synced(&ctx).await?; + // For platform-only tools, skip this step and add: + // // No SPV gate — queries DAPI directly // 5. Build and dispatch the backend task let task = BackendTask::DomainTask(DomainTask::MyVariant { ... }); @@ -97,7 +99,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` **only** for tools that operate on the core chain — sending Dash, generating receive addresses, reading core balances, creating asset lock transactions. Skip it for tools that query DAPI / Platform directly (platform address balances, withdrawals, identity credit operations, shielded transfers). When skipping, add a comment: `// No SPV gate — queries DAPI directly`. ### 6. Register in `tool_router()` diff --git a/docs/MCP.md b/docs/MCP.md index 7053ecd31..7c531523e 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -90,6 +90,10 @@ 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 + +Core-chain tools (`core_address_create`, `core_balances_get`, `core_funds_send`, `identity_credits_topup`, `shielded_shield_from_core`) wait for SPV to fully sync before executing. Platform-only tools (`platform_addresses_list`, `platform_withdrawals_get`, `identity_credits_*` except topup, `shielded_shield_from_platform`, `shielded_transfer`, `shielded_unshield`, `shielded_withdraw`) query DAPI directly and do **not** require SPV. + ## 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/src/mcp/resolve.rs b/src/mcp/resolve.rs index ef0c42d20..9752b56de 100644 --- a/src/mcp/resolve.rs +++ b/src/mcp/resolve.rs @@ -105,6 +105,11 @@ pub(crate) fn wallet_arc( } /// Wait for SPV to reach fully-synced (green) state. +/// +/// Only required for tools that operate on the **core chain** (UTXOs, sending +/// Dash, generating receive addresses). Platform-only tools that query DAPI +/// (e.g. `platform_addresses_list`, `platform_withdrawals_get`) must **not** +/// call this — they work without SPV. 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/tools/wallet.rs b/src/mcp/tools/wallet.rs index 8b76506ab..123430687 100644 --- a/src/mcp/tools/wallet.rs +++ b/src/mcp/tools/wallet.rs @@ -342,7 +342,9 @@ impl AsyncTool for FetchPlatformBalances { resolve::verify_network(&ctx, param.network.as_deref())?; let seed_hash = resolve::wallet(&ctx, ¶m.wallet_id)?; - resolve::ensure_spv_synced(&ctx).await?; + // No SPV gate — platform address balances are fetched from DAPI, not the + // local SPV chain. Requiring SPV here caused the tool to hang when SPV + // was unavailable (e.g. stale lock on the data directory). let task = BackendTask::WalletTask(WalletTask::FetchPlatformAddressBalances { seed_hash }); let result = dispatch_task(&ctx, task) From 5e5ca4835afa6b6fb7b4cd10eb1c8e24a57349de Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:26:50 +0200 Subject: [PATCH 45/48] docs: rename EXPOSING_BACKEND_TASKS.md to MCP_TOOL_DEVELOPMENT.md More discoverable name for the MCP/CLI tool development guide. Updates references in CLAUDE.md and CONTRIBUTING.md. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 2 +- CONTRIBUTING.md | 2 +- docs/{EXPOSING_BACKEND_TASKS.md => MCP_TOOL_DEVELOPMENT.md} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename docs/{EXPOSING_BACKEND_TASKS.md => MCP_TOOL_DEVELOPMENT.md} (100%) diff --git a/CLAUDE.md b/CLAUDE.md index dedc9ef40..ed6bea124 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,7 +115,7 @@ User-facing error messages (shown in `MessageBanner` via `Display`) must follow - **Backend dispatch**: tools reuse the app's `BackendTask` system via `dispatch::dispatch_task()` — creates a throwaway channel, calls `app_context.run_backend_task()`. - **Schema quirk**: `schemars` v1 derives bare `true` for `serde_json::Value` fields — some MCP clients reject this. Use `#[schemars(transform)]` to override. - **Error type**: `McpToolError` enum (InvalidParam, WalletNotFound, SpvSyncFailed, TaskFailed, Internal) converts to `rmcp::ErrorData` via `From`. -- **Docs**: `docs/MCP.md` (server config, tool reference), `docs/CLI.md` (usage, examples), `docs/EXPOSING_BACKEND_TASKS.md` (checklist for adding new MCP tools). +- **Docs**: `docs/MCP.md` (server config, tool reference), `docs/CLI.md` (usage, examples), `docs/MCP_TOOL_DEVELOPMENT.md` (checklist for adding new MCP tools). ### Key Dependencies diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4579b8409..a00f17f4d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -76,7 +76,7 @@ The default `cargo build` produces only the `dash-evo-tool` GUI binary. Optional ### Adding MCP tools -To expose a `BackendTask` as a new MCP/CLI tool, follow the step-by-step checklist in [docs/EXPOSING_BACKEND_TASKS.md](docs/EXPOSING_BACKEND_TASKS.md). It covers architecture rules, the standard invocation pattern, registration, and common pitfalls. +To expose a `BackendTask` as a new MCP/CLI tool, follow the step-by-step checklist in [docs/MCP_TOOL_DEVELOPMENT.md](docs/MCP_TOOL_DEVELOPMENT.md). It covers architecture rules, the standard invocation pattern, registration, and common pitfalls. ## Code quality diff --git a/docs/EXPOSING_BACKEND_TASKS.md b/docs/MCP_TOOL_DEVELOPMENT.md similarity index 100% rename from docs/EXPOSING_BACKEND_TASKS.md rename to docs/MCP_TOOL_DEVELOPMENT.md From 73f6ac6dd79e7160c276ca37389b3e8e199cba79 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:33:28 +0200 Subject: [PATCH 46/48] fix(mcp): restore SPV gate on platform_addresses_list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DAPI proof verification requires quorum and masternode list data from the synced SPV chain. When a second DET instance runs concurrently, SPV falls back to a tempdir and must sync before any proof verification works — including platform-only queries. Updates docs to clarify that ALL wallet-facing tools need SPV, not just core-chain tools. Co-Authored-By: Claude Opus 4.6 --- docs/MCP.md | 4 +++- docs/MCP_TOOL_DEVELOPMENT.md | 6 ++---- src/mcp/resolve.rs | 12 ++++++++---- src/mcp/tools/wallet.rs | 7 ++++--- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/docs/MCP.md b/docs/MCP.md index 7c531523e..d46b96619 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -92,7 +92,9 @@ Parameters marked `?` are optional. The `det-cli` column shows the equivalent CL ### SPV requirements -Core-chain tools (`core_address_create`, `core_balances_get`, `core_funds_send`, `identity_credits_topup`, `shielded_shield_from_core`) wait for SPV to fully sync before executing. Platform-only tools (`platform_addresses_list`, `platform_withdrawals_get`, `identity_credits_*` except topup, `shielded_shield_from_platform`, `shielded_transfer`, `shielded_unshield`, `shielded_withdraw`) query DAPI directly and do **not** require SPV. +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) diff --git a/docs/MCP_TOOL_DEVELOPMENT.md b/docs/MCP_TOOL_DEVELOPMENT.md index 53abd7a28..a53676b44 100644 --- a/docs/MCP_TOOL_DEVELOPMENT.md +++ b/docs/MCP_TOOL_DEVELOPMENT.md @@ -74,10 +74,8 @@ impl AsyncTool for MyNewTool { // 3. Resolve wallet if needed let seed_hash = resolve::wallet(&ctx, ¶m.wallet_id)?; - // 4. Wait for SPV sync — ONLY for core-chain tools (see SPV gate rule) + // 4. Wait for SPV sync (see SPV gate rule below) resolve::ensure_spv_synced(&ctx).await?; - // For platform-only tools, skip this step and add: - // // No SPV gate — queries DAPI directly // 5. Build and dispatch the backend task let task = BackendTask::DomainTask(DomainTask::MyVariant { ... }); @@ -99,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. -- **SPV gate rule**: Call `ensure_spv_synced` **only** for tools that operate on the core chain — sending Dash, generating receive addresses, reading core balances, creating asset lock transactions. Skip it for tools that query DAPI / Platform directly (platform address balances, withdrawals, identity credit operations, shielded transfers). When skipping, add a comment: `// No SPV gate — queries DAPI directly`. +- **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/mcp/resolve.rs b/src/mcp/resolve.rs index 9752b56de..469c4aa44 100644 --- a/src/mcp/resolve.rs +++ b/src/mcp/resolve.rs @@ -106,10 +106,14 @@ pub(crate) fn wallet_arc( /// Wait for SPV to reach fully-synced (green) state. /// -/// Only required for tools that operate on the **core chain** (UTXOs, sending -/// Dash, generating receive addresses). Platform-only tools that query DAPI -/// (e.g. `platform_addresses_list`, `platform_withdrawals_get`) must **not** -/// call this — they work without SPV. +/// 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/tools/wallet.rs b/src/mcp/tools/wallet.rs index 123430687..3819a4d55 100644 --- a/src/mcp/tools/wallet.rs +++ b/src/mcp/tools/wallet.rs @@ -342,9 +342,10 @@ impl AsyncTool for FetchPlatformBalances { resolve::verify_network(&ctx, param.network.as_deref())?; let seed_hash = resolve::wallet(&ctx, ¶m.wallet_id)?; - // No SPV gate — platform address balances are fetched from DAPI, not the - // local SPV chain. Requiring SPV here caused the tool to hang when SPV - // was unavailable (e.g. stale lock on the data directory). + // 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 }); let result = dispatch_task(&ctx, task) From 8f215069d3b9b44a4f2124d2416e64dae9000208 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:35:32 +0200 Subject: [PATCH 47/48] fix(mcp): include error details in TaskFailed MCP responses The catch-all SdkError TaskError variant shows a generic user-friendly message via Display. MCP clients (unlike GUI users) benefit from seeing the full error chain for debugging. Attach the Debug representation in the JSON-RPC error data field so the underlying SDK/DAPI error is visible. Co-Authored-By: Claude Opus 4.6 --- src/mcp/error.rs | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) 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, } } } From 6be5388c32f3f64a7be68283696b5ae434273c06 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:19:08 +0200 Subject: [PATCH 48/48] fix(mcp): use PlatformAddress in PlatformAddressBalances backend task result Move Core->Platform address conversion from consumers into the backend task producer. The PlatformAddressBalances variant now carries BTreeMap instead of BTreeMap, so the MCP tool can call to_bech32m_string() directly and the UI consumer converts back to Core Address only for wallet storage. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend_task/mod.rs | 7 +++---- .../wallet/fetch_platform_address_balances.rs | 7 ++++++- src/mcp/tools/wallet.rs | 3 ++- src/ui/wallets/wallets_screen/mod.rs | 8 +++++--- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index 0f47c8628..d02fd7648 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -10,8 +10,7 @@ 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 dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::dashcore::bls_sig_utils::BLSSignature; use dash_sdk::dpp::dashcore::network::message_qrinfo::QRInfo; use dash_sdk::dpp::dashcore::BlockHash; @@ -193,8 +192,8 @@ pub enum BackendTaskSuccessResult { /// Platform address balances fetched from Platform PlatformAddressBalances { seed_hash: WalletSeedHash, - /// Map of address to (balance, nonce) - balances: BTreeMap, (u64, u32)>, + /// Map of platform address to (balance, nonce) + balances: BTreeMap, }, /// Platform credits transferred between addresses PlatformCreditsTransferred { diff --git a/src/backend_task/wallet/fetch_platform_address_balances.rs b/src/backend_task/wallet/fetch_platform_address_balances.rs index 359d73802..d1fb8f32b 100644 --- a/src/backend_task/wallet/fetch_platform_address_balances.rs +++ b/src/backend_task/wallet/fetch_platform_address_balances.rs @@ -5,6 +5,7 @@ use crate::model::wallet::{ WalletSeedHash, }; use dash_sdk::RequestSettings; +use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::key_wallet::bip32::DerivationPath; use dash_sdk::platform::address_sync::AddressSyncConfig; @@ -169,7 +170,11 @@ impl AppContext { wallet .platform_address_info .iter() - .map(|(addr, info)| (addr.clone(), (info.balance, info.nonce))) + .filter_map(|(addr, info)| { + PlatformAddress::try_from(addr.clone()) + .ok() + .map(|pa| (pa, (info.balance, info.nonce))) + }) .collect() }; diff --git a/src/mcp/tools/wallet.rs b/src/mcp/tools/wallet.rs index 8b76506ab..4969dc03f 100644 --- a/src/mcp/tools/wallet.rs +++ b/src/mcp/tools/wallet.rs @@ -351,10 +351,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/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 91afb10b6..ca6acdb9d 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -2881,9 +2881,11 @@ impl ScreenLike for WalletsBalancesScreen { && let Ok(mut wallet) = selected.write() && wallet.seed_hash() == seed_hash { - // Update balances in the wallet - for (addr, (balance, nonce)) in balances { - wallet.set_platform_address_info(addr, balance, nonce); + // Convert PlatformAddress back to Core Address for wallet storage + let network = self.app_context.network(); + for (platform_addr, (balance, nonce)) in balances { + let core_addr = platform_addr.to_address_with_network(network); + wallet.set_platform_address_info(core_addr, balance, nonce); } } self.refresh_platform_sync_info_cache(&seed_hash);