From 64fab209dfaa5cd887a79552af6c2c9d2ed6665d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 18 Feb 2026 00:57:26 +0700 Subject: [PATCH 001/147] more work --- Cargo.lock | 584 ++++++++++++++++++++-- Cargo.toml | 7 +- src/app.rs | 7 + src/backend_task/mod.rs | 32 ++ src/backend_task/shielded/bundle.rs | 261 ++++++++++ src/backend_task/shielded/mod.rs | 43 ++ src/backend_task/shielded/nullifiers.rs | 81 +++ src/backend_task/shielded/sync.rs | 198 ++++++++ src/context/mod.rs | 9 + src/context/shielded.rs | 311 ++++++++++++ src/database/initialization.rs | 8 +- src/database/mod.rs | 11 + src/database/shielded.rs | 251 ++++++++++ src/model/wallet/mod.rs | 1 + src/model/wallet/shielded.rs | 167 +++++++ src/ui/mod.rs | 66 +++ src/ui/wallets/mod.rs | 4 + src/ui/wallets/shield_credits_screen.rs | 218 ++++++++ src/ui/wallets/shielded_send_screen.rs | 233 +++++++++ src/ui/wallets/shielded_tab.rs | 473 ++++++++++++++++++ src/ui/wallets/unshield_credits_screen.rs | 237 +++++++++ src/ui/wallets/wallets_screen/mod.rs | 159 ++++-- 22 files changed, 3278 insertions(+), 83 deletions(-) create mode 100644 src/backend_task/shielded/bundle.rs create mode 100644 src/backend_task/shielded/mod.rs create mode 100644 src/backend_task/shielded/nullifiers.rs create mode 100644 src/backend_task/shielded/sync.rs create mode 100644 src/context/shielded.rs create mode 100644 src/database/shielded.rs create mode 100644 src/model/wallet/shielded.rs create mode 100644 src/ui/wallets/shield_credits_screen.rs create mode 100644 src/ui/wallets/shielded_send_screen.rs create mode 100644 src/ui/wallets/shielded_tab.rs create mode 100644 src/ui/wallets/unshield_credits_screen.rs diff --git a/Cargo.lock b/Cargo.lock index dfe01d188..ed0abbbdf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -971,6 +971,17 @@ dependencies = [ "digest", ] +[[package]] +name = "blake2b_simd" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79834656f71332577234b50bfc009996f7449e0c056884e6a02492ded0ca2f3" +dependencies = [ + "arrayref", + "arrayvec", + "constant_time_eq", +] + [[package]] name = "blake3" version = "1.8.3" @@ -1040,6 +1051,17 @@ dependencies = [ "piper", ] +[[package]] +name = "bls12_381" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7bc6d6292be3a19e6379786dac800f551e5865a5bb51ebbe3064ab80433f403" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "blsful" version = "3.0.0" @@ -1269,6 +1291,30 @@ dependencies = [ "libc", ] +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.43" @@ -1349,6 +1395,16 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common", "inout", + "zeroize", +] + +[[package]] +name = "ckb-merkle-mountain-range" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327c468ff2702c0a2b7ad26728a180611da22244882959b8b2e85c79bf56bcea" +dependencies = [ + "cfg-if", ] [[package]] @@ -1544,6 +1600,15 @@ dependencies = [ "libc", ] +[[package]] +name = "core2" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "239fa3ae9b63c2dc74bd3fa852d4792b8b305ae64eeede946265b6af62f1fff3" +dependencies = [ + "memchr", +] + [[package]] name = "core2" version = "0.4.0" @@ -1717,7 +1782,6 @@ dependencies = [ [[package]] name = "dapi-grpc" version = "3.0.1" -source = "git+https://github.com/dashpay/platform?rev=d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7#d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7" dependencies = [ "dash-platform-macros", "futures-core", @@ -1785,7 +1849,6 @@ dependencies = [ [[package]] name = "dash-context-provider" version = "3.0.1" -source = "git+https://github.com/dashpay/platform?rev=d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7#d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7" dependencies = [ "dpp", "drive", @@ -1855,6 +1918,7 @@ dependencies = [ "winres", "zeroize", "zeromq", + "zip32", "zmq", "zxcvbn", ] @@ -1873,7 +1937,6 @@ dependencies = [ [[package]] name = "dash-platform-macros" version = "3.0.1" -source = "git+https://github.com/dashpay/platform?rev=d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7#d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7" dependencies = [ "heck", "quote", @@ -1883,7 +1946,6 @@ dependencies = [ [[package]] name = "dash-sdk" version = "3.0.1" -source = "git+https://github.com/dashpay/platform?rev=d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7#d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7" dependencies = [ "arc-swap", "async-trait", @@ -1900,6 +1962,7 @@ dependencies = [ "drive-proof-verifier", "envy", "futures", + "grovedb-commitment-tree", "hex", "http", "js-sys", @@ -2034,7 +2097,6 @@ dependencies = [ [[package]] name = "dashpay-contract" version = "3.0.1" -source = "git+https://github.com/dashpay/platform?rev=d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7#d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7" dependencies = [ "platform-value", "platform-version", @@ -2045,7 +2107,6 @@ dependencies = [ [[package]] name = "data-contracts" version = "3.0.1" -source = "git+https://github.com/dashpay/platform?rev=d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7#d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7" dependencies = [ "dashpay-contract", "dpns-contract", @@ -2298,7 +2359,6 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "dpns-contract" version = "3.0.1" -source = "git+https://github.com/dashpay/platform?rev=d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7#d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7" dependencies = [ "platform-value", "platform-version", @@ -2309,7 +2369,6 @@ dependencies = [ [[package]] name = "dpp" version = "3.0.1" -source = "git+https://github.com/dashpay/platform?rev=d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7#d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7" dependencies = [ "anyhow", "async-trait", @@ -2328,6 +2387,7 @@ dependencies = [ "derive_more 1.0.0", "env_logger", "getrandom 0.2.17", + "grovedb-commitment-tree", "hex", "indexmap 2.13.0", "integer-encoding", @@ -2357,17 +2417,16 @@ dependencies = [ [[package]] name = "drive" version = "3.0.1" -source = "git+https://github.com/dashpay/platform?rev=d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7#d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7" dependencies = [ "bincode 2.0.1", "byteorder", "derive_more 1.0.0", "dpp", - "grovedb", - "grovedb-costs", + "grovedb 4.0.0", + "grovedb-costs 4.0.0", "grovedb-epoch-based-storage-flags", - "grovedb-path", - "grovedb-version", + "grovedb-path 4.0.0", + "grovedb-version 4.0.0", "hex", "indexmap 2.13.0", "integer-encoding", @@ -2382,7 +2441,6 @@ dependencies = [ [[package]] name = "drive-proof-verifier" version = "3.0.1" -source = "git+https://github.com/dashpay/platform?rev=d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7#d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7" dependencies = [ "bincode 2.0.1", "dapi-grpc", @@ -2964,7 +3022,6 @@ dependencies = [ [[package]] name = "feature-flags-contract" version = "3.0.1" -source = "git+https://github.com/dashpay/platform?rev=d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7#d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7" dependencies = [ "platform-value", "platform-version", @@ -3116,6 +3173,20 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8866fac38f53fc87fa3ae1b09ddd723e0482f8fa74323518b4c59df2c55a00a" +[[package]] +name = "fpe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26c4b37de5ae15812a764c958297cfc50f5c010438f60c6ce75d11b802abd404" +dependencies = [ + "cbc", + "cipher", + "libm", + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -3302,6 +3373,18 @@ dependencies = [ "wasip3", ] +[[package]] +name = "getset" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.115", +] + [[package]] name = "ghash" version = "0.5.1" @@ -3487,12 +3570,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", + "memuse", "rand 0.8.5", "rand_core 0.6.4", "rand_xorshift", "subtle", ] +[[package]] +name = "grovedb" +version = "4.0.0" +dependencies = [ + "bincode 2.0.1", + "bincode_derive", + "blake3", + "ckb-merkle-mountain-range", + "grovedb-bulk-append-tree", + "grovedb-costs 4.0.0", + "grovedb-dense-fixed-sized-merkle-tree", + "grovedb-element 4.0.0", + "grovedb-merk 4.0.0", + "grovedb-mmr", + "grovedb-path 4.0.0", + "grovedb-version 4.0.0", + "hex", + "hex-literal", + "indexmap 2.13.0", + "integer-encoding", + "reqwest 0.12.28", + "sha2", + "thiserror 2.0.18", +] + [[package]] name = "grovedb" version = "4.0.0" @@ -3501,11 +3610,11 @@ dependencies = [ "bincode 2.0.1", "bincode_derive", "blake3", - "grovedb-costs", - "grovedb-element", - "grovedb-merk", - "grovedb-path", - "grovedb-version", + "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", + "grovedb-element 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", + "grovedb-merk 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", + "grovedb-path 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", + "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", "hex", "hex-literal", "indexmap 2.13.0", @@ -3515,6 +3624,38 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "grovedb-bulk-append-tree" +version = "4.0.0" +dependencies = [ + "bincode 2.0.1", + "blake3", + "grovedb-dense-fixed-sized-merkle-tree", + "grovedb-mmr", + "hex", + "thiserror 2.0.18", +] + +[[package]] +name = "grovedb-commitment-tree" +version = "4.0.0" +dependencies = [ + "incrementalmerkletree", + "orchard", + "shardtree", + "thiserror 2.0.18", + "zcash_note_encryption", +] + +[[package]] +name = "grovedb-costs" +version = "4.0.0" +dependencies = [ + "integer-encoding", + "intmap", + "thiserror 2.0.18", +] + [[package]] name = "grovedb-costs" version = "4.0.0" @@ -3525,6 +3666,28 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "grovedb-dense-fixed-sized-merkle-tree" +version = "4.0.0" +dependencies = [ + "bincode 2.0.1", + "blake3", + "thiserror 2.0.18", +] + +[[package]] +name = "grovedb-element" +version = "4.0.0" +dependencies = [ + "bincode 2.0.1", + "bincode_derive", + "grovedb-path 4.0.0", + "grovedb-version 4.0.0", + "hex", + "integer-encoding", + "thiserror 2.0.18", +] + [[package]] name = "grovedb-element" version = "4.0.0" @@ -3532,8 +3695,8 @@ source = "git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424 dependencies = [ "bincode 2.0.1", "bincode_derive", - "grovedb-path", - "grovedb-version", + "grovedb-path 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", + "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", "hex", "integer-encoding", "thiserror 2.0.18", @@ -3542,15 +3705,34 @@ dependencies = [ [[package]] name = "grovedb-epoch-based-storage-flags" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897#33dfd48a1718160cb333fa95424be491785f1897" dependencies = [ - "grovedb-costs", + "grovedb-costs 4.0.0", "hex", "integer-encoding", "intmap", "thiserror 2.0.18", ] +[[package]] +name = "grovedb-merk" +version = "4.0.0" +dependencies = [ + "bincode 2.0.1", + "bincode_derive", + "blake3", + "byteorder", + "ed", + "grovedb-costs 4.0.0", + "grovedb-element 4.0.0", + "grovedb-path 4.0.0", + "grovedb-version 4.0.0", + "grovedb-visualize 4.0.0", + "hex", + "indexmap 2.13.0", + "integer-encoding", + "thiserror 2.0.18", +] + [[package]] name = "grovedb-merk" version = "4.0.0" @@ -3561,17 +3743,34 @@ dependencies = [ "blake3", "byteorder", "ed", - "grovedb-costs", - "grovedb-element", - "grovedb-path", - "grovedb-version", - "grovedb-visualize", + "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", + "grovedb-element 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", + "grovedb-path 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", + "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", + "grovedb-visualize 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", "hex", "indexmap 2.13.0", "integer-encoding", "thiserror 2.0.18", ] +[[package]] +name = "grovedb-mmr" +version = "4.0.0" +dependencies = [ + "bincode 2.0.1", + "blake3", + "ckb-merkle-mountain-range", + "thiserror 2.0.18", +] + +[[package]] +name = "grovedb-path" +version = "4.0.0" +dependencies = [ + "hex", +] + [[package]] name = "grovedb-path" version = "4.0.0" @@ -3580,6 +3779,14 @@ dependencies = [ "hex", ] +[[package]] +name = "grovedb-version" +version = "4.0.0" +dependencies = [ + "thiserror 2.0.18", + "versioned-feature-core 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "grovedb-version" version = "4.0.0" @@ -3589,6 +3796,14 @@ dependencies = [ "versioned-feature-core 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "grovedb-visualize" +version = "4.0.0" +dependencies = [ + "hex", + "itertools 0.14.0", +] + [[package]] name = "grovedb-visualize" version = "4.0.0" @@ -3612,9 +3827,9 @@ dependencies = [ "curve25519-dalek", "ed25519-dalek", "env_logger", - "grovedb", - "grovedb-costs", - "grovedb-merk", + "grovedb 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", + "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", + "grovedb-merk 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", "hex", "log", "num-bigint", @@ -3666,6 +3881,61 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "halo2_gadgets" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45824ce0dd12e91ec0c68ebae2a7ed8ae19b70946624c849add59f1d1a62a143" +dependencies = [ + "arrayvec", + "bitvec", + "ff", + "group", + "halo2_poseidon", + "halo2_proofs", + "lazy_static", + "pasta_curves", + "rand 0.8.5", + "sinsemilla", + "subtle", + "uint", +] + +[[package]] +name = "halo2_legacy_pdqsort" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47716fe1ae67969c5e0b2ef826f32db8c3be72be325e1aa3c1951d06b5575ec5" + +[[package]] +name = "halo2_poseidon" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa3da60b81f02f9b33ebc6252d766f843291fb4d2247a07ae73d20b791fc56f" +dependencies = [ + "bitvec", + "ff", + "group", + "pasta_curves", +] + +[[package]] +name = "halo2_proofs" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05713f117155643ce10975e0bee44a274bcda2f4bb5ef29a999ad67c1fa8d4d3" +dependencies = [ + "blake2b_simd", + "ff", + "group", + "halo2_legacy_pdqsort", + "indexmap 1.9.3", + "maybe-rayon", + "pasta_curves", + "rand_core 0.6.4", + "tracing", +] + [[package]] name = "hash32" version = "0.3.1" @@ -4171,6 +4441,15 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" +[[package]] +name = "incrementalmerkletree" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30821f91f0fa8660edca547918dc59812893b497d07c1144f326f07fdd94aba9" +dependencies = [ + "either", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -4363,6 +4642,20 @@ dependencies = [ "serde_json", ] +[[package]] +name = "jubjub" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8499f7a74008aafbecb2a2e608a3e13e4dd3e84df198b604451efe93f2de6e61" +dependencies = [ + "bitvec", + "bls12_381", + "ff", + "group", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "jwalk" version = "0.8.1" @@ -4428,7 +4721,6 @@ dependencies = [ [[package]] name = "keyword-search-contract" version = "3.0.1" -source = "git+https://github.com/dashpay/platform?rev=d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7#d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7" dependencies = [ "platform-value", "platform-version", @@ -4489,6 +4781,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin 0.9.8", +] [[package]] name = "lazycell" @@ -4620,7 +4915,6 @@ dependencies = [ [[package]] name = "masternode-reward-shares-contract" version = "3.0.1" -source = "git+https://github.com/dashpay/platform?rev=d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7#d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7" dependencies = [ "platform-value", "platform-version", @@ -4637,6 +4931,16 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + [[package]] name = "memchr" version = "2.8.0" @@ -4661,6 +4965,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "memuse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d97bbf43eb4f088f8ca469930cde17fa036207c9a5e02ccc5107c4e8b17c964" + [[package]] name = "merlin" version = "3.0.0" @@ -4920,6 +5230,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "nonempty" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "549e471b99ccaf2f89101bec68f4d244457d5a95a9c3d0672e9564124397741d" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -5447,6 +5763,40 @@ dependencies = [ "libredox", ] +[[package]] +name = "orchard" +version = "0.12.0" +dependencies = [ + "aes", + "bitvec", + "blake2b_simd", + "core2 0.3.3", + "ff", + "fpe", + "getset", + "group", + "halo2_gadgets", + "halo2_poseidon", + "halo2_proofs", + "hex", + "incrementalmerkletree", + "lazy_static", + "memuse", + "nonempty", + "pasta_curves", + "rand 0.8.5", + "rand_core 0.6.4", + "reddsa", + "serde", + "sinsemilla", + "subtle", + "tracing", + "visibility", + "zcash_note_encryption", + "zcash_spec", + "zip32", +] + [[package]] name = "ordered-float" version = "5.1.0" @@ -5533,6 +5883,21 @@ dependencies = [ "subtle", ] +[[package]] +name = "pasta_curves" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e57598f73cc7e1b2ac63c79c517b31a0877cd7c402cdcaa311b5208de7a095" +dependencies = [ + "blake2b_simd", + "ff", + "group", + "lazy_static", + "rand 0.8.5", + "static_assertions", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -5684,7 +6049,6 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "platform-serialization" version = "3.0.1" -source = "git+https://github.com/dashpay/platform?rev=d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7#d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7" dependencies = [ "bincode 2.0.1", "platform-version", @@ -5693,7 +6057,6 @@ dependencies = [ [[package]] name = "platform-serialization-derive" version = "3.0.1" -source = "git+https://github.com/dashpay/platform?rev=d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7#d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7" dependencies = [ "proc-macro2", "quote", @@ -5704,7 +6067,6 @@ dependencies = [ [[package]] name = "platform-value" version = "3.0.1" -source = "git+https://github.com/dashpay/platform?rev=d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7#d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7" dependencies = [ "base64 0.22.1", "bincode 2.0.1", @@ -5724,10 +6086,9 @@ dependencies = [ [[package]] name = "platform-version" version = "3.0.1" -source = "git+https://github.com/dashpay/platform?rev=d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7#d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7" dependencies = [ "bincode 2.0.1", - "grovedb-version", + "grovedb-version 4.0.0", "thiserror 2.0.18", "versioned-feature-core 1.0.0 (git+https://github.com/dashpay/versioned-feature-core)", ] @@ -5735,7 +6096,6 @@ dependencies = [ [[package]] name = "platform-versioning" version = "3.0.1" -source = "git+https://github.com/dashpay/platform?rev=d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7#d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7" dependencies = [ "proc-macro2", "quote", @@ -5788,6 +6148,17 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "polyval" version = "0.6.2" @@ -5874,6 +6245,28 @@ dependencies = [ "toml_edit 0.23.10+spec-1.0.0", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.115", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -6182,6 +6575,24 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "reddsa" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78a5191930e84973293aa5f532b513404460cd2216c1cfb76d08748c15b40b02" +dependencies = [ + "blake2b_simd", + "byteorder", + "group", + "hex", + "jubjub", + "pasta_curves", + "rand_core 0.6.4", + "serde", + "thiserror 1.0.69", + "zeroize", +] + [[package]] name = "redox_syscall" version = "0.4.1" @@ -6444,7 +6855,6 @@ dependencies = [ [[package]] name = "rs-dapi-client" version = "3.0.1" -source = "git+https://github.com/dashpay/platform?rev=d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7#d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7" dependencies = [ "backon", "chrono", @@ -6987,6 +7397,18 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shardtree" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "637e95dcd06bc1bb3f86ed9db1e1832a70125f32daae071ef37dcb7701b7d4fe" +dependencies = [ + "bitflags 2.10.0", + "either", + "incrementalmerkletree", + "tracing", +] + [[package]] name = "shlex" version = "1.3.0" @@ -7027,6 +7449,17 @@ dependencies = [ "log", ] +[[package]] +name = "sinsemilla" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d268ae0ea06faafe1662e9967cd4f9022014f5eeb798e0c302c876df8b7af9c" +dependencies = [ + "group", + "pasta_curves", + "subtle", +] + [[package]] name = "siphasher" version = "1.0.2" @@ -7146,6 +7579,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "spin" version = "0.10.0" @@ -7212,7 +7651,7 @@ checksum = "227c4f8561598188d0df96dbe749824576174bba278b5b6bb2eacff1066067d0" dependencies = [ "hashbrown 0.16.1", "rustversion", - "spin", + "spin 0.10.0", ] [[package]] @@ -7597,7 +8036,6 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "token-history-contract" version = "3.0.1" -source = "git+https://github.com/dashpay/platform?rev=d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7#d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7" dependencies = [ "platform-value", "platform-version", @@ -8040,13 +8478,25 @@ dependencies = [ "winapi", ] +[[package]] +name = "uint" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f64bba2c53b04fcab63c01a7d7427eadc821e3bc48c34dc9ba29c501164b52" +dependencies = [ + "byteorder", + "crunchy", + "hex", + "static_assertions", +] + [[package]] name = "uint-zigzag" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "abbf77aed65cb885a8ba07138c365879be3d9a93dce82bf6cc50feca9138ec15" dependencies = [ - "core2", + "core2 0.4.0", ] [[package]] @@ -8314,6 +8764,17 @@ version = "0.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" +[[package]] +name = "visibility" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + [[package]] name = "vsss-rs" version = "5.1.0" @@ -8345,7 +8806,6 @@ dependencies = [ [[package]] name = "wallet-utils-contract" version = "3.0.1" -source = "git+https://github.com/dashpay/platform?rev=d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7#d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7" dependencies = [ "platform-value", "platform-version", @@ -9752,7 +10212,6 @@ dependencies = [ [[package]] name = "withdrawals-contract" version = "3.0.1" -source = "git+https://github.com/dashpay/platform?rev=d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7#d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7" dependencies = [ "num_enum 0.5.11", "platform-value", @@ -9967,6 +10426,26 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zcash_note_encryption" +version = "0.4.1" +dependencies = [ + "chacha20", + "chacha20poly1305", + "cipher", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "zcash_spec" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ded3f58b93486aa79b85acba1001f5298f27a46489859934954d262533ee2915" +dependencies = [ + "blake2b_simd", +] + [[package]] name = "zerocopy" version = "0.8.39" @@ -10113,6 +10592,19 @@ dependencies = [ "zopfli", ] +[[package]] +name = "zip32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b64bf5186a8916f7a48f2a98ef599bf9c099e2458b36b819e393db1c0e768c4b" +dependencies = [ + "bech32 0.11.1", + "blake2b_simd", + "memuse", + "subtle", + "zcash_spec", +] + [[package]] name = "zlib-rs" version = "0.6.0" diff --git a/Cargo.toml b/Cargo.toml index 89326e918..26c545351 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ qrcode = "0.14.1" nix = { version = "0.31.1", features = ["signal"] } eframe = { version = "0.33.3", features = ["persistence"] } base64 = "0.22.1" -dash-sdk = { git = "https://github.com/dashpay/platform", rev = "d6f4eb9ac9feafaa914f06e1b78eb66beceef3b7", features = [ +dash-sdk = { git = "https://github.com/dashpay/platform", branch = "feat/zk", features = [ "core_key_wallet", "core_key_wallet_manager", "core_bincode", @@ -26,7 +26,9 @@ dash-sdk = { git = "https://github.com/dashpay/platform", rev = "d6f4eb9ac9feafa "core_verification", "core_rpc_client", "core_spv", + "shielded", ] } +zip32 = "0.2.0" grovestark = { git = "https://www.github.com/dashpay/grovestark", rev = "5b9e289cca54c79b1305d5f4f40bf1148f1eb0e3" } rayon = "1.8" thiserror = "2.0.18" @@ -101,5 +103,8 @@ dashcore_hashes = { git = "https://www.github.com/dashpay/rust-dashcore", branch key-wallet = { git = "https://www.github.com/dashpay/rust-dashcore", branch = "v0.42-dev" } key-wallet-manager = { git = "https://www.github.com/dashpay/rust-dashcore", branch = "v0.42-dev" } +[patch."https://github.com/dashpay/platform"] +dash-sdk = { path = "../platform/packages/rs-sdk" } + [lints.clippy] uninlined_format_args = "allow" diff --git a/src/app.rs b/src/app.rs index c11d96189..4b286f8fd 100644 --- a/src/app.rs +++ b/src/app.rs @@ -662,6 +662,13 @@ impl AppState { } } + // Warm up the Halo 2 ProvingKey in a background thread (~30s build). + // This ensures the key is ready for the user's first shielded operation. + std::thread::spawn(|| { + let _ = crate::context::shielded::get_proving_key(); + tracing::info!("Halo 2 ProvingKey built and cached"); + }); + app_state } diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index 7d8413fad..1a31e685c 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -37,6 +37,7 @@ use dash_sdk::query_types::{Documents, IndexMap}; use futures::future::join_all; use std::collections::BTreeMap; use std::sync::Arc; +use shielded::ShieldedTask; use tokens::TokenTask; use grovestark::GroveSTARKTask; @@ -51,6 +52,7 @@ pub mod identity; pub mod mnlist; pub mod platform_info; pub mod register_contract; +pub mod shielded; pub mod system_task; pub mod tokens; pub mod update_data_contract; @@ -92,6 +94,7 @@ pub enum BackendTask { PlatformInfo(PlatformInfoTaskRequestType), GroveSTARKTask(GroveSTARKTask), WalletTask(WalletTask), + ShieldedTask(ShieldedTask), None, } @@ -266,6 +269,34 @@ pub enum BackendTaskSuccessResult { // Broadcast results BroadcastedStateTransition, + + // Shielded pool results + ShieldedInitialized { + seed_hash: WalletSeedHash, + balance: u64, + }, + ShieldedNotesSynced { + seed_hash: WalletSeedHash, + new_notes: u32, + balance: u64, + }, + ShieldedCreditsShielded { + seed_hash: WalletSeedHash, + amount: u64, + }, + ShieldedTransferComplete { + seed_hash: WalletSeedHash, + amount: u64, + }, + ShieldedCreditsUnshielded { + seed_hash: WalletSeedHash, + amount: u64, + }, + ShieldedNullifiersChecked { + seed_hash: WalletSeedHash, + spent_count: u32, + }, + ProvingKeyReady, } impl BackendTaskSuccessResult {} @@ -351,6 +382,7 @@ impl AppContext { grovestark::run_grovestark_task(grovestark_task, &sdk).await } BackendTask::WalletTask(wallet_task) => self.run_wallet_task(wallet_task).await, + BackendTask::ShieldedTask(shielded_task) => self.run_shielded_task(shielded_task).await, BackendTask::None => Ok(BackendTaskSuccessResult::None), } } diff --git a/src/backend_task/shielded/bundle.rs b/src/backend_task/shielded/bundle.rs new file mode 100644 index 000000000..12b9065a0 --- /dev/null +++ b/src/backend_task/shielded/bundle.rs @@ -0,0 +1,261 @@ +use crate::context::AppContext; +use crate::context::shielded::get_proving_key; +use crate::model::wallet::WalletSeedHash; +use crate::model::wallet::shielded::ShieldedWalletState; +use dash_sdk::dpp::address_funds::{AddressFundsFeeStrategy, OrchardAddress, PlatformAddress}; +use dash_sdk::dpp::shielded::builder::{ + SpendableNote, build_shield_transition, build_shielded_transfer_transition, + build_unshield_transition, +}; +use dash_sdk::grovedb_commitment_tree::PaymentAddress; +use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; +use std::collections::BTreeMap; +use std::sync::Arc; + +/// Build and broadcast a Shield transition (transparent -> shielded pool). +/// +/// Uses the DPP builder which handles Orchard bundle construction internally +/// (including Halo 2 proof generation and RedPallas signature application). +pub async fn shield_credits( + app_context: &Arc, + seed_hash: &WalletSeedHash, + shielded_state: &ShieldedWalletState, + amount: u64, + from_address: PlatformAddress, +) -> Result<(), String> { + let sdk = { + let guard = app_context.sdk.read().unwrap(); + guard.clone() + }; + + let proving_key = get_proving_key(); + + // Build recipient Orchard address from our default payment address + let recipient_addr = payment_address_to_orchard(&shielded_state.keys.default_address); + + // Get the wallet for signing and nonce lookup + let wallet_arc = { + let wallets = app_context.wallets.read().unwrap(); + wallets.get(seed_hash).cloned().ok_or("Wallet not found")? + }; + + // Get the nonce for the input address from the wallet's platform address info + let (nonce, _balance) = { + let wallet = wallet_arc.read().unwrap(); + wallet + .platform_address_info + .iter() + .find_map(|(addr, info)| { + let platform_addr = PlatformAddress::try_from(addr.clone()).ok()?; + if platform_addr == from_address { + Some((info.nonce + 1, info.balance)) + } else { + None + } + }) + .ok_or("Platform address not found in wallet")? + }; + + let mut inputs = BTreeMap::new(); + inputs.insert(from_address, (nonce, amount)); + + let fee_strategy: AddressFundsFeeStrategy = vec![]; + + // Use the DPP builder which handles bundle construction internally + let state_transition = { + let wallet = wallet_arc.read().unwrap(); + build_shield_transition( + &recipient_addr, + amount, + inputs, + fee_strategy, + &*wallet, + 0, + proving_key, + [0u8; 36], + sdk.version(), + ) + .map_err(|e| format!("Failed to build shield transition: {e}"))? + }; + + state_transition + .broadcast(&sdk, None) + .await + .map_err(|e| format!("Failed to broadcast shield transition: {e}"))?; + + Ok(()) +} + +/// Build and broadcast a ShieldedTransfer transition (pool -> pool). +pub async fn shielded_transfer( + app_context: &Arc, + _seed_hash: &WalletSeedHash, + shielded_state: &ShieldedWalletState, + amount: u64, + recipient_address_bytes: &[u8], +) -> Result<(), String> { + let sdk = { + let guard = app_context.sdk.read().unwrap(); + guard.clone() + }; + + let proving_key = get_proving_key(); + + // Parse recipient address + let recipient_bytes: [u8; 43] = recipient_address_bytes + .try_into() + .map_err(|_| "Invalid recipient address length, expected 43 bytes")?; + let recipient_addr = OrchardAddress::from_raw_bytes(&recipient_bytes); + + // Select notes to spend + let (spendable_notes, _total_value) = select_notes_for_amount(shielded_state, amount)?; + + // Get Merkle witness for each note + let spends = spendable_notes + .iter() + .map(|note| { + let merkle_path = shielded_state + .commitment_tree + .witness(note.position, 0) + .map_err(|e| format!("Failed to get Merkle witness: {e}"))? + .ok_or("No Merkle path available for note")?; + Ok(SpendableNote { + note: note.note, + merkle_path, + }) + }) + .collect::, String>>()?; + + let anchor = shielded_state + .commitment_tree + .anchor() + .map_err(|e| format!("Failed to get tree anchor: {e}"))?; + + let change_addr = payment_address_to_orchard(&shielded_state.keys.default_address); + + let state_transition = build_shielded_transfer_transition( + spends, + &recipient_addr, + amount, + &change_addr, + &shielded_state.keys.fvk, + &shielded_state.keys.ask, + anchor, + proving_key, + [0u8; 36], + sdk.version(), + ) + .map_err(|e| format!("Failed to build shielded transfer: {e}"))?; + + state_transition + .broadcast(&sdk, None) + .await + .map_err(|e| format!("Failed to broadcast shielded transfer: {e}"))?; + + Ok(()) +} + +/// Build and broadcast an Unshield transition (shielded pool -> platform address). +pub async fn unshield_credits( + app_context: &Arc, + _seed_hash: &WalletSeedHash, + shielded_state: &ShieldedWalletState, + amount: u64, + to_platform_address: PlatformAddress, +) -> Result<(), String> { + let sdk = { + let guard = app_context.sdk.read().unwrap(); + guard.clone() + }; + + let proving_key = get_proving_key(); + + // Select notes to spend + let (spendable_notes, _total_value) = select_notes_for_amount(shielded_state, amount)?; + + // Get Merkle witness for each note + let spends = spendable_notes + .iter() + .map(|note| { + let merkle_path = shielded_state + .commitment_tree + .witness(note.position, 0) + .map_err(|e| format!("Failed to get Merkle witness: {e}"))? + .ok_or("No Merkle path available for note")?; + Ok(SpendableNote { + note: note.note, + merkle_path, + }) + }) + .collect::, String>>()?; + + let anchor = shielded_state + .commitment_tree + .anchor() + .map_err(|e| format!("Failed to get tree anchor: {e}"))?; + + let change_addr = payment_address_to_orchard(&shielded_state.keys.default_address); + + let state_transition = build_unshield_transition( + spends, + to_platform_address, + amount, + &change_addr, + &shielded_state.keys.fvk, + &shielded_state.keys.ask, + anchor, + proving_key, + [0u8; 36], + sdk.version(), + ) + .map_err(|e| format!("Failed to build unshield transition: {e}"))?; + + state_transition + .broadcast(&sdk, None) + .await + .map_err(|e| format!("Failed to broadcast unshield transition: {e}"))?; + + Ok(()) +} + +/// Select notes to cover the requested amount using a greedy algorithm. +fn select_notes_for_amount( + shielded_state: &ShieldedWalletState, + amount: u64, +) -> Result<(Vec<&crate::model::wallet::shielded::ShieldedNote>, u64), String> { + let unspent: Vec<_> = shielded_state.unspent_notes(); + + if unspent.is_empty() { + return Err("No unspent shielded notes available".to_string()); + } + + let total_available: u64 = unspent.iter().map(|n| n.value).sum(); + if total_available < amount { + return Err(format!( + "Insufficient shielded balance: have {}, need {}", + total_available, amount + )); + } + + let mut sorted: Vec<_> = unspent; + sorted.sort_by(|a, b| b.value.cmp(&a.value)); + + let mut selected = Vec::new(); + let mut accumulated = 0u64; + + for note in sorted { + selected.push(note); + accumulated += note.value; + if accumulated >= amount { + break; + } + } + + Ok((selected, accumulated)) +} + +/// Convert a PaymentAddress to an OrchardAddress for the builder functions. +fn payment_address_to_orchard(addr: &PaymentAddress) -> OrchardAddress { + let raw = addr.to_raw_address_bytes(); + OrchardAddress::from_raw_bytes(&raw) +} diff --git a/src/backend_task/shielded/mod.rs b/src/backend_task/shielded/mod.rs new file mode 100644 index 000000000..070b8b59e --- /dev/null +++ b/src/backend_task/shielded/mod.rs @@ -0,0 +1,43 @@ +pub mod bundle; +pub mod nullifiers; +pub mod sync; + +use crate::model::wallet::WalletSeedHash; +use dash_sdk::dpp::address_funds::PlatformAddress; + +#[derive(Debug, Clone, PartialEq)] +pub enum ShieldedTask { + /// Initialize shielded state for a wallet (ZIP32 key derivation, load tree from DB) + InitializeShieldedWallet { seed_hash: WalletSeedHash }, + + /// Sync encrypted notes from platform (trial decrypt, update tree) + SyncNotes { seed_hash: WalletSeedHash }, + + /// Shield credits from platform address into the shielded pool (Type 15) + ShieldCredits { + seed_hash: WalletSeedHash, + amount: u64, + from_address: PlatformAddress, + }, + + /// Private transfer within the shielded pool (Type 16) + ShieldedTransfer { + seed_hash: WalletSeedHash, + amount: u64, + /// Serialized Orchard PaymentAddress bytes + recipient_address_bytes: Vec, + }, + + /// Unshield credits to a platform address (Type 17) + UnshieldCredits { + seed_hash: WalletSeedHash, + amount: u64, + to_platform_address: PlatformAddress, + }, + + /// Check nullifiers to detect spent notes + CheckNullifiers { seed_hash: WalletSeedHash }, + + /// Warm up the proving key in background (~30s) + WarmUpProvingKey, +} diff --git a/src/backend_task/shielded/nullifiers.rs b/src/backend_task/shielded/nullifiers.rs new file mode 100644 index 000000000..9d24d9a41 --- /dev/null +++ b/src/backend_task/shielded/nullifiers.rs @@ -0,0 +1,81 @@ +use crate::context::AppContext; +use crate::model::wallet::WalletSeedHash; +use crate::model::wallet::shielded::ShieldedWalletState; +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::platform::Fetch; +use dash_sdk::query_types::{ShieldedNullifierStatuses, ShieldedNullifiersQuery}; +use std::sync::Arc; + +/// Check which unspent notes have been spent on-chain by querying their nullifiers. +/// +/// For each unspent note, queries the platform for the nullifier status. +/// Notes whose nullifiers are found spent are marked as such in both +/// the in-memory state and the database. +pub async fn check_nullifiers( + app_context: &Arc, + seed_hash: &WalletSeedHash, + shielded_state: &mut ShieldedWalletState, + network: Network, +) -> Result { + let sdk = { + let guard = app_context.sdk.read().unwrap(); + guard.clone() + }; + + let network_str = network.to_string(); + + // Collect nullifiers of unspent notes + let unspent_nullifiers: Vec> = shielded_state + .notes + .iter() + .filter(|n| !n.is_spent) + .map(|n| n.nullifier.to_bytes().to_vec()) + .collect(); + + if unspent_nullifiers.is_empty() { + return Ok(0); + } + + let query = ShieldedNullifiersQuery(unspent_nullifiers); + + let statuses: Option = ShieldedNullifierStatuses::fetch(&sdk, query) + .await + .map_err(|e| format!("Failed to fetch nullifier statuses: {e}"))?; + + let statuses = match statuses { + Some(s) => s.0, + None => return Ok(0), + }; + + let mut spent_count = 0u32; + + for status in statuses { + if status.is_spent { + let nullifier_bytes: [u8; 32] = status + .nullifier + .try_into() + .map_err(|_| "Invalid nullifier length")?; + + // Mark as spent in memory + for note in &mut shielded_state.notes { + if !note.is_spent && note.nullifier.to_bytes() == nullifier_bytes { + note.is_spent = true; + spent_count += 1; + + // Mark as spent in DB + let _ = app_context.db.mark_shielded_note_spent( + seed_hash, + &nullifier_bytes, + &network_str, + ); + } + } + } + } + + if spent_count > 0 { + shielded_state.recalculate_balance(); + } + + Ok(spent_count) +} diff --git a/src/backend_task/shielded/sync.rs b/src/backend_task/shielded/sync.rs new file mode 100644 index 000000000..7683f3426 --- /dev/null +++ b/src/backend_task/shielded/sync.rs @@ -0,0 +1,198 @@ +use crate::context::AppContext; +use crate::model::wallet::WalletSeedHash; +use crate::model::wallet::shielded::{ShieldedNote, ShieldedWalletState}; +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::grovedb_commitment_tree::{ + COMPACT_NOTE_SIZE, CompactAction, DashMemo, EphemeralKeyBytes, ExtractedNoteCommitment, Note, + Nullifier, OrchardDomain, Position, PreparedIncomingViewingKey, Retention, + try_compact_note_decryption, +}; +use dash_sdk::platform::Fetch; +use dash_sdk::query_types::{ + ShieldedEncryptedNote, ShieldedEncryptedNotes, ShieldedEncryptedNotesQuery, +}; +use std::sync::Arc; + +const SYNC_BATCH_SIZE: u32 = 1000; + +/// Minimum size of the `encrypted_note` field from the RPC response. +/// +/// The stored value format in GroveDB is: `cmx(32) || nullifier(32) || encrypted_note(216)`. +/// After proof verification splits at 32 bytes, the `encrypted_note` field contains: +/// nullifier(32) || epk(32) || enc_ciphertext(104) || out_ciphertext(80) = 248 bytes. +const MIN_ENCRYPTED_NOTE_LEN: usize = 32 + 32 + COMPACT_NOTE_SIZE; + +/// Sync encrypted notes from the platform's BulkAppendTree. +/// +/// For each encrypted note: +/// 1. Trial-decrypt with the wallet's IVK using compact note decryption +/// 2. If decrypted, compute nullifier and store as ShieldedNote +/// 3. Append ALL cmx values to the ClientCommitmentTree +/// (Retention::Marked for our notes, Retention::Ephemeral for others) +pub async fn sync_notes( + app_context: &Arc, + seed_hash: &WalletSeedHash, + shielded_state: &mut ShieldedWalletState, + network: Network, +) -> Result<(u32, u64), String> { + let sdk = { + let guard = app_context.sdk.read().unwrap(); + guard.clone() + }; + + let network_str = network.to_string(); + let prepared_ivk = shielded_state.keys.ivk.prepare(); + let mut new_note_count = 0u32; + let mut start_index = shielded_state.last_synced_index; + let mut checkpoint_id = start_index as u32; + + loop { + let query = ShieldedEncryptedNotesQuery { + start_index, + count: SYNC_BATCH_SIZE, + }; + + let notes_result: Option = + ShieldedEncryptedNotes::fetch(&sdk, query) + .await + .map_err(|e| format!("Failed to fetch encrypted notes: {e}"))?; + + let notes = match notes_result { + Some(n) => n.0, + None => break, + }; + + if notes.is_empty() { + break; + } + + let batch_len = notes.len() as u64; + + for (i, encrypted_note) in notes.into_iter().enumerate() { + let global_position = start_index + i as u64; + let position = Position::from(global_position); + + let cmx_bytes: [u8; 32] = encrypted_note + .cmx + .clone() + .try_into() + .map_err(|_| "Invalid cmx length")?; + + // Try to trial-decrypt with our IVK + if let Some(note) = try_decrypt_note(&prepared_ivk, &encrypted_note, &cmx_bytes) { + let nullifier = note.nullifier(&shielded_state.keys.fvk); + let value = note.value().inner(); + let note_data = crate::model::wallet::shielded::serialize_note(¬e); + + let shielded_note = ShieldedNote { + note, + position, + cmx: cmx_bytes, + nullifier, + block_height: 0, // Not available from encrypted notes query + is_spent: false, + value, + }; + + // Persist to DB + let nullifier_bytes = nullifier.to_bytes(); + let _ = app_context.db.insert_shielded_note( + seed_hash, + &crate::database::shielded::InsertShieldedNote { + note_data: ¬e_data, + position: global_position, + cmx: &cmx_bytes, + nullifier: &nullifier_bytes, + block_height: 0, + value, + network: &network_str, + }, + ); + + shielded_state.notes.push(shielded_note); + new_note_count += 1; + + // Mark in commitment tree (we need witness for this note) + shielded_state + .commitment_tree + .append(cmx_bytes, Retention::Marked) + .map_err(|e| format!("Failed to append marked note to tree: {e}"))?; + } else { + // Not our note, but still need to track in tree for correct Merkle paths + shielded_state + .commitment_tree + .append(cmx_bytes, Retention::Ephemeral) + .map_err(|e| format!("Failed to append ephemeral note to tree: {e}"))?; + } + } + + start_index += batch_len; + checkpoint_id += 1; + + // Checkpoint the tree + shielded_state + .commitment_tree + .checkpoint(checkpoint_id) + .map_err(|e| format!("Failed to checkpoint tree: {e}"))?; + + // If we got fewer notes than the batch size, we've caught up + if batch_len < SYNC_BATCH_SIZE as u64 { + break; + } + } + + shielded_state.last_synced_index = start_index; + shielded_state.recalculate_balance(); + + // Save tree state to DB + let _ = app_context.db.save_shielded_tree_state( + seed_hash, + &[], + shielded_state.last_synced_index, + &network_str, + ); + + Ok((new_note_count, shielded_state.shielded_balance)) +} + +/// Attempt compact trial decryption on an encrypted note from the RPC. +/// +/// The `encrypted_note.encrypted_note` field contains (after proof verification): +/// `nullifier(32) || epk(32) || enc_ciphertext(104) || out_ciphertext(80)` +/// +/// For compact decryption we only need the first 116 bytes: +/// - nullifier(32): derives Rho via `Rho::from_nf_old(nullifier)` for OrchardDomain +/// - epk(32): ephemeral public key for key agreement +/// - enc_compact(52): first COMPACT_NOTE_SIZE bytes of enc_ciphertext +/// (version + diversifier + value + rseed) +fn try_decrypt_note( + ivk: &PreparedIncomingViewingKey, + encrypted_note: &ShieldedEncryptedNote, + cmx_bytes: &[u8; 32], +) -> Option { + let data = &encrypted_note.encrypted_note; + if data.len() < MIN_ENCRYPTED_NOTE_LEN { + return None; + } + + // Parse nullifier (first 32 bytes) + let nf_bytes: [u8; 32] = data[0..32].try_into().ok()?; + let nf = Nullifier::from_bytes(&nf_bytes).into_option()?; + + // Parse cmx + let cmx = ExtractedNoteCommitment::from_bytes(cmx_bytes).into_option()?; + + // Parse ephemeral public key (next 32 bytes) + let epk_bytes: [u8; 32] = data[32..64].try_into().ok()?; + + // Parse compact ciphertext (first COMPACT_NOTE_SIZE bytes of enc_ciphertext) + let enc_compact: [u8; COMPACT_NOTE_SIZE] = data[64..64 + COMPACT_NOTE_SIZE].try_into().ok()?; + + // Build CompactAction and OrchardDomain for trial decryption + let compact = CompactAction::from_parts(nf, cmx, EphemeralKeyBytes(epk_bytes), enc_compact); + let domain = OrchardDomain::::for_compact_action(&compact); + + // Attempt decryption — returns (Note, PaymentAddress) if this note belongs to us + let (note, _address) = try_compact_note_decryption(&domain, ivk, &compact)?; + Some(note) +} diff --git a/src/context/mod.rs b/src/context/mod.rs index 3ccaf835d..280749539 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -2,6 +2,7 @@ pub mod connection_status; mod contract_token_db; mod identity_db; mod settings_db; +pub mod shielded; mod transaction_processing; mod wallet_lifecycle; @@ -97,6 +98,13 @@ pub struct AppContext { /// Cached fee multiplier permille from current epoch (1000 = 1x, 2000 = 2x) /// Updated when epoch info is fetched from Platform fee_multiplier_permille: AtomicU64, + /// Per-wallet shielded state (initialized lazily, keyed by wallet seed hash) + pub(crate) shielded_states: Mutex< + std::collections::HashMap< + WalletSeedHash, + crate::model::wallet::shielded::ShieldedWalletState, + >, + >, } impl AppContext { @@ -270,6 +278,7 @@ impl AppContext { fee_multiplier_permille: AtomicU64::new( PlatformFeeEstimator::DEFAULT_FEE_MULTIPLIER_PERMILLE, ), + shielded_states: Mutex::new(std::collections::HashMap::new()), }; let app_context = Arc::new(app_context); diff --git a/src/context/shielded.rs b/src/context/shielded.rs new file mode 100644 index 000000000..fba0d088b --- /dev/null +++ b/src/context/shielded.rs @@ -0,0 +1,311 @@ +use std::sync::OnceLock; + +use crate::backend_task::BackendTaskSuccessResult; +use crate::backend_task::shielded::ShieldedTask; +use crate::context::AppContext; +use crate::model::wallet::shielded::{ShieldedNote, ShieldedWalletState, derive_orchard_keys}; +use dash_sdk::grovedb_commitment_tree::{Nullifier, Position, ProvingKey}; +use std::sync::Arc; + +static PROVING_KEY: OnceLock = OnceLock::new(); + +/// Get or build the Halo 2 ProvingKey (cached for app lifetime). +/// +/// The first call takes ~30 seconds to build the key. Subsequent calls return +/// immediately from the cache. Use `warm_up_proving_key()` on a background +/// thread at app startup to avoid blocking the user's first shielded operation. +pub fn get_proving_key() -> &'static ProvingKey { + PROVING_KEY.get_or_init(ProvingKey::build) +} + +/// Whether the proving key has already been built and cached. +pub fn is_proving_key_ready() -> bool { + PROVING_KEY.get().is_some() +} + +impl AppContext { + /// Run a shielded pool task. + pub async fn run_shielded_task( + self: &Arc, + task: ShieldedTask, + ) -> Result { + match task { + ShieldedTask::WarmUpProvingKey => { + let _ = get_proving_key(); + Ok(BackendTaskSuccessResult::ProvingKeyReady) + } + + ShieldedTask::InitializeShieldedWallet { seed_hash } => { + self.initialize_shielded_wallet(seed_hash) + } + + ShieldedTask::SyncNotes { seed_hash } => self.sync_shielded_notes(seed_hash).await, + + ShieldedTask::ShieldCredits { + seed_hash, + amount, + from_address, + } => { + self.shield_credits_task(seed_hash, amount, from_address) + .await + } + + ShieldedTask::ShieldedTransfer { + seed_hash, + amount, + recipient_address_bytes, + } => { + self.shielded_transfer_task(seed_hash, amount, recipient_address_bytes) + .await + } + + ShieldedTask::UnshieldCredits { + seed_hash, + amount, + to_platform_address, + } => { + self.unshield_credits_task(seed_hash, amount, to_platform_address) + .await + } + + ShieldedTask::CheckNullifiers { seed_hash } => { + self.check_nullifiers_task(seed_hash).await + } + } + } + + /// Initialize shielded wallet state by deriving ZIP32 keys from the wallet seed. + fn initialize_shielded_wallet( + self: &Arc, + seed_hash: crate::model::wallet::WalletSeedHash, + ) -> Result { + // Check if already initialized + { + let states = self.shielded_states.lock().unwrap(); + if states.contains_key(&seed_hash) { + let balance = states + .get(&seed_hash) + .map(|s| s.shielded_balance) + .unwrap_or(0); + return Ok(BackendTaskSuccessResult::ShieldedInitialized { seed_hash, balance }); + } + } + + // Get the wallet seed + let seed_bytes = { + let wallets = self.wallets.read().unwrap(); + let wallet_arc = wallets.get(&seed_hash).ok_or("Wallet not found")?; + let wallet = wallet_arc.read().unwrap(); + match &wallet.wallet_seed { + crate::model::wallet::WalletSeed::Open(open) => open.seed, + crate::model::wallet::WalletSeed::Closed(_) => { + return Err("Wallet must be unlocked to initialize shielded state".to_string()); + } + } + }; + + // Derive Orchard keys via ZIP32 + let keys = derive_orchard_keys(&seed_bytes, self.network, 0)?; + + let network_str = self.network.to_string(); + let mut state = ShieldedWalletState::new(keys); + + // Tree is always rebuilt from scratch (ClientCommitmentTree has no serde). + // Notes are loaded from DB for instant balance; tree needs full re-sync. + state.last_synced_index = 0; + + // Load persisted notes from DB and reconstruct Note objects + if let Ok(note_rows) = self.db.get_unspent_shielded_notes(&seed_hash, &network_str) { + for row in note_rows { + if let Some(note) = crate::model::wallet::shielded::deserialize_note(&row.note_data) + && let Some(nullifier) = Nullifier::from_bytes(&row.nullifier).into_option() + { + state.notes.push(ShieldedNote { + note, + position: Position::from(row.position), + cmx: row.cmx, + nullifier, + block_height: row.block_height, + is_spent: false, + value: row.value, + }); + } + } + state.recalculate_balance(); + } + + let balance = state.shielded_balance; + + let mut states = self.shielded_states.lock().unwrap(); + states.insert(seed_hash, state); + + Ok(BackendTaskSuccessResult::ShieldedInitialized { seed_hash, balance }) + } + + /// Sync shielded notes from platform. + async fn sync_shielded_notes( + self: &Arc, + seed_hash: crate::model::wallet::WalletSeedHash, + ) -> Result { + // Take the state temporarily for the async operation + let mut state = { + let mut states = self.shielded_states.lock().unwrap(); + states + .remove(&seed_hash) + .ok_or("Shielded wallet not initialized")? + }; + + let result = crate::backend_task::shielded::sync::sync_notes( + self, + &seed_hash, + &mut state, + self.network, + ) + .await; + + // Put state back + { + let mut states = self.shielded_states.lock().unwrap(); + states.insert(seed_hash, state); + } + + let (new_notes, balance) = result?; + Ok(BackendTaskSuccessResult::ShieldedNotesSynced { + seed_hash, + new_notes, + balance, + }) + } + + /// Shield credits from a platform address into the shielded pool. + async fn shield_credits_task( + self: &Arc, + seed_hash: crate::model::wallet::WalletSeedHash, + amount: u64, + from_address: dash_sdk::dpp::address_funds::PlatformAddress, + ) -> Result { + let state_ref = { + let mut states = self.shielded_states.lock().unwrap(); + states + .remove(&seed_hash) + .ok_or("Shielded wallet not initialized")? + }; + + let result = crate::backend_task::shielded::bundle::shield_credits( + self, + &seed_hash, + &state_ref, + amount, + from_address, + ) + .await; + + // Put state back + { + let mut states = self.shielded_states.lock().unwrap(); + states.insert(seed_hash, state_ref); + } + + result?; + Ok(BackendTaskSuccessResult::ShieldedCreditsShielded { seed_hash, amount }) + } + + /// Transfer credits within the shielded pool. + async fn shielded_transfer_task( + self: &Arc, + seed_hash: crate::model::wallet::WalletSeedHash, + amount: u64, + recipient_address_bytes: Vec, + ) -> Result { + let state = { + let mut states = self.shielded_states.lock().unwrap(); + states + .remove(&seed_hash) + .ok_or("Shielded wallet not initialized")? + }; + + let result = crate::backend_task::shielded::bundle::shielded_transfer( + self, + &seed_hash, + &state, + amount, + &recipient_address_bytes, + ) + .await; + + // Put state back + { + let mut states = self.shielded_states.lock().unwrap(); + states.insert(seed_hash, state); + } + + result?; + Ok(BackendTaskSuccessResult::ShieldedTransferComplete { seed_hash, amount }) + } + + /// Unshield credits from the shielded pool to a platform address. + async fn unshield_credits_task( + self: &Arc, + seed_hash: crate::model::wallet::WalletSeedHash, + amount: u64, + to_platform_address: dash_sdk::dpp::address_funds::PlatformAddress, + ) -> Result { + let state = { + let mut states = self.shielded_states.lock().unwrap(); + states + .remove(&seed_hash) + .ok_or("Shielded wallet not initialized")? + }; + + let result = crate::backend_task::shielded::bundle::unshield_credits( + self, + &seed_hash, + &state, + amount, + to_platform_address, + ) + .await; + + // Put state back + { + let mut states = self.shielded_states.lock().unwrap(); + states.insert(seed_hash, state); + } + + result?; + Ok(BackendTaskSuccessResult::ShieldedCreditsUnshielded { seed_hash, amount }) + } + + /// Check nullifiers to detect spent notes. + async fn check_nullifiers_task( + self: &Arc, + seed_hash: crate::model::wallet::WalletSeedHash, + ) -> Result { + let mut state = { + let mut states = self.shielded_states.lock().unwrap(); + states + .remove(&seed_hash) + .ok_or("Shielded wallet not initialized")? + }; + + let result = crate::backend_task::shielded::nullifiers::check_nullifiers( + self, + &seed_hash, + &mut state, + self.network, + ) + .await; + + // Put state back + { + let mut states = self.shielded_states.lock().unwrap(); + states.insert(seed_hash, state); + } + + let spent_count = result?; + Ok(BackendTaskSuccessResult::ShieldedNullifiersChecked { + seed_hash, + spent_count, + }) + } +} diff --git a/src/database/initialization.rs b/src/database/initialization.rs index a9cf640e0..3c5331299 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -4,7 +4,7 @@ use rusqlite::{Connection, params}; use std::fs; use std::path::Path; -pub const DEFAULT_DB_VERSION: u16 = 27; +pub const DEFAULT_DB_VERSION: u16 = 28; pub const DEFAULT_NETWORK: &str = "dash"; @@ -51,6 +51,9 @@ impl Database { fn apply_version_changes(&self, version: u16, tx: &Connection) -> rusqlite::Result<()> { match version { + 28 => { + self.create_shielded_tables(tx)?; + } 27 => { self.add_network_indexes(tx)?; } @@ -506,6 +509,9 @@ impl Database { // Initialize single key wallet table self.initialize_single_key_wallet_table(&conn)?; + // Initialize shielded pool tables + self.create_shielded_tables(&conn)?; + Ok(()) } diff --git a/src/database/mod.rs b/src/database/mod.rs index c719d0bb7..996abb27d 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -8,6 +8,7 @@ mod initialization; mod proof_log; mod scheduled_votes; mod settings; +pub mod shielded; mod single_key_wallet; #[cfg(test)] pub mod test_helpers; @@ -139,6 +140,16 @@ impl Database { rusqlite::params![&network_str], )?; + tx.execute( + "DELETE FROM shielded_notes WHERE network = ?1", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM shielded_tree_state WHERE network = ?1", + rusqlite::params![&network_str], + )?; + tx.commit() } } diff --git a/src/database/shielded.rs b/src/database/shielded.rs new file mode 100644 index 000000000..5b6b86fcd --- /dev/null +++ b/src/database/shielded.rs @@ -0,0 +1,251 @@ +use crate::database::Database; +use crate::model::wallet::WalletSeedHash; +use rusqlite::{Connection, params}; + +impl Database { + /// Create shielded pool tables (v28 migration). + pub(crate) fn create_shielded_tables(&self, conn: &Connection) -> rusqlite::Result<()> { + conn.execute( + "CREATE TABLE IF NOT EXISTS shielded_notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + wallet_seed_hash BLOB NOT NULL, + note_data BLOB NOT NULL, + position INTEGER NOT NULL, + cmx BLOB NOT NULL, + nullifier BLOB NOT NULL, + block_height INTEGER NOT NULL, + is_spent INTEGER NOT NULL DEFAULT 0, + value INTEGER NOT NULL, + network TEXT NOT NULL, + UNIQUE(wallet_seed_hash, nullifier, network) + )", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_shielded_notes_wallet_network + ON shielded_notes (wallet_seed_hash, network)", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS shielded_tree_state ( + wallet_seed_hash BLOB NOT NULL, + network TEXT NOT NULL, + tree_data BLOB NOT NULL, + last_synced_index INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (wallet_seed_hash, network) + )", + [], + )?; + + Ok(()) + } + + /// Insert a shielded note into the database. + pub fn insert_shielded_note( + &self, + wallet_seed_hash: &WalletSeedHash, + note: &InsertShieldedNote<'_>, + ) -> rusqlite::Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT OR IGNORE INTO shielded_notes + (wallet_seed_hash, note_data, position, cmx, nullifier, block_height, value, network) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + wallet_seed_hash.as_slice(), + note.note_data, + note.position as i64, + note.cmx.as_slice(), + note.nullifier.as_slice(), + note.block_height as i64, + note.value as i64, + note.network, + ], + )?; + Ok(()) + } + + /// Get all unspent shielded notes for a wallet on a given network. + pub fn get_unspent_shielded_notes( + &self, + wallet_seed_hash: &WalletSeedHash, + network: &str, + ) -> rusqlite::Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, note_data, position, cmx, nullifier, block_height, value + FROM shielded_notes + WHERE wallet_seed_hash = ?1 AND network = ?2 AND is_spent = 0 + ORDER BY position ASC", + )?; + + let rows = stmt.query_map(params![wallet_seed_hash.as_slice(), network], |row| { + Ok(ShieldedNoteRow { + id: row.get(0)?, + note_data: row.get(1)?, + position: row.get::<_, i64>(2)? as u64, + cmx: { + let bytes: Vec = row.get(3)?; + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bytes); + arr + }, + nullifier: { + let bytes: Vec = row.get(4)?; + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bytes); + arr + }, + block_height: row.get::<_, i64>(5)? as u64, + value: row.get::<_, i64>(6)? as u64, + }) + })?; + + rows.collect() + } + + /// Get all shielded notes (spent and unspent) for a wallet on a given network. + pub fn get_all_shielded_notes( + &self, + wallet_seed_hash: &WalletSeedHash, + network: &str, + ) -> rusqlite::Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, note_data, position, cmx, nullifier, block_height, value + FROM shielded_notes + WHERE wallet_seed_hash = ?1 AND network = ?2 + ORDER BY position ASC", + )?; + + let rows = stmt.query_map(params![wallet_seed_hash.as_slice(), network], |row| { + Ok(ShieldedNoteRow { + id: row.get(0)?, + note_data: row.get(1)?, + position: row.get::<_, i64>(2)? as u64, + cmx: { + let bytes: Vec = row.get(3)?; + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bytes); + arr + }, + nullifier: { + let bytes: Vec = row.get(4)?; + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bytes); + arr + }, + block_height: row.get::<_, i64>(5)? as u64, + value: row.get::<_, i64>(6)? as u64, + }) + })?; + + rows.collect() + } + + /// Mark a shielded note as spent by its nullifier. + pub fn mark_shielded_note_spent( + &self, + wallet_seed_hash: &WalletSeedHash, + nullifier: &[u8; 32], + network: &str, + ) -> rusqlite::Result { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE shielded_notes SET is_spent = 1 + WHERE wallet_seed_hash = ?1 AND nullifier = ?2 AND network = ?3", + params![wallet_seed_hash.as_slice(), nullifier.as_slice(), network], + ) + } + + /// Save the serialized commitment tree state for a wallet. + pub fn save_shielded_tree_state( + &self, + wallet_seed_hash: &WalletSeedHash, + tree_data: &[u8], + last_synced_index: u64, + network: &str, + ) -> rusqlite::Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO shielded_tree_state (wallet_seed_hash, network, tree_data, last_synced_index) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(wallet_seed_hash, network) + DO UPDATE SET tree_data = excluded.tree_data, last_synced_index = excluded.last_synced_index", + params![ + wallet_seed_hash.as_slice(), + network, + tree_data, + last_synced_index as i64, + ], + )?; + Ok(()) + } + + /// Load the commitment tree state for a wallet. + pub fn load_shielded_tree_state( + &self, + wallet_seed_hash: &WalletSeedHash, + network: &str, + ) -> rusqlite::Result, u64)>> { + let conn = self.conn.lock().unwrap(); + let result = conn.query_row( + "SELECT tree_data, last_synced_index FROM shielded_tree_state + WHERE wallet_seed_hash = ?1 AND network = ?2", + params![wallet_seed_hash.as_slice(), network], + |row| { + let tree_data: Vec = row.get(0)?; + let last_synced_index: i64 = row.get(1)?; + Ok((tree_data, last_synced_index as u64)) + }, + ); + + match result { + Ok(data) => Ok(Some(data)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e), + } + } + + /// Get total shielded balance (sum of unspent note values) for a wallet. + pub fn get_shielded_balance( + &self, + wallet_seed_hash: &WalletSeedHash, + network: &str, + ) -> rusqlite::Result { + let conn = self.conn.lock().unwrap(); + let result: i64 = conn + .query_row( + "SELECT COALESCE(SUM(value), 0) FROM shielded_notes + WHERE wallet_seed_hash = ?1 AND network = ?2 AND is_spent = 0", + params![wallet_seed_hash.as_slice(), network], + |row| row.get(0), + ) + .unwrap_or(0); + Ok(result as u64) + } +} + +/// Parameters for inserting a shielded note. +pub struct InsertShieldedNote<'a> { + pub note_data: &'a [u8], + pub position: u64, + pub cmx: &'a [u8; 32], + pub nullifier: &'a [u8; 32], + pub block_height: u64, + pub value: u64, + pub network: &'a str, +} + +/// Row data for a shielded note from the database. +pub struct ShieldedNoteRow { + pub id: i64, + pub note_data: Vec, + pub position: u64, + pub cmx: [u8; 32], + pub nullifier: [u8; 32], + pub block_height: u64, + pub value: u64, +} diff --git a/src/model/wallet/mod.rs b/src/model/wallet/mod.rs index 50deb6f78..b972640ab 100644 --- a/src/model/wallet/mod.rs +++ b/src/model/wallet/mod.rs @@ -1,5 +1,6 @@ mod asset_lock_transaction; pub mod encryption; +pub mod shielded; pub mod single_key; mod utxos; diff --git a/src/model/wallet/shielded.rs b/src/model/wallet/shielded.rs new file mode 100644 index 000000000..e006b630e --- /dev/null +++ b/src/model/wallet/shielded.rs @@ -0,0 +1,167 @@ +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::grovedb_commitment_tree::RandomSeed; +use dash_sdk::grovedb_commitment_tree::{ + ClientCommitmentTree, FullViewingKey, IncomingViewingKey, Note, NoteValue, Nullifier, + OutgoingViewingKey, PaymentAddress, Position, Rho, Scope, SpendAuthorizingKey, SpendingKey, +}; +use zip32::AccountId; + +/// Dash coin types per BIP44 +const DASH_COIN_TYPE_MAINNET: u32 = 5; +const DASH_COIN_TYPE_TESTNET: u32 = 1; + +/// Orchard key set derived from a wallet seed via ZIP32. +/// +/// ZIP32 derivation path: `m / 32' / coin_type' / account'` +/// where `purpose = 32` is the ZIP number for Orchard key derivation. +pub struct OrchardKeySet { + pub sk: SpendingKey, + pub fvk: FullViewingKey, + pub ask: SpendAuthorizingKey, + pub ivk: IncomingViewingKey, + pub ovk: OutgoingViewingKey, + pub default_address: PaymentAddress, +} + +/// Derive Orchard keys from an HD wallet seed using ZIP32. +/// +/// The seed can be 32-252 bytes. We pass the wallet's 64-byte BIP39 seed directly. +/// `SpendingKey::from_zip32_seed()` internally derives +/// `ExtendedSpendingKey::from_path(seed, &[32', coin_type', account'])`. +pub fn derive_orchard_keys( + seed_bytes: &[u8], + network: Network, + account: u32, +) -> Result { + let coin_type = match network { + Network::Dash => DASH_COIN_TYPE_MAINNET, + _ => DASH_COIN_TYPE_TESTNET, + }; + let account_id = + AccountId::try_from(account).map_err(|_| "Invalid account index".to_string())?; + + let sk = SpendingKey::from_zip32_seed(seed_bytes, coin_type, account_id) + .map_err(|e| format!("ZIP32 derivation failed: {e}"))?; + + let fvk = FullViewingKey::from(&sk); + let ask = SpendAuthorizingKey::from(&sk); + let ivk = fvk.to_ivk(Scope::External); + let ovk = fvk.to_ovk(Scope::External); + let default_address = fvk.address_at(0u32, Scope::External); + + Ok(OrchardKeySet { + sk, + fvk, + ask, + ivk, + ovk, + default_address, + }) +} + +/// A decrypted shielded note owned by the wallet. +pub struct ShieldedNote { + /// The decrypted Orchard note + pub note: Note, + /// Position in the commitment tree (global index) + pub position: Position, + /// Extracted note commitment bytes + pub cmx: [u8; 32], + /// Nullifier for detecting when spent + pub nullifier: Nullifier, + /// Block height where the note appeared + pub block_height: u64, + /// Whether the nullifier was seen on-chain + pub is_spent: bool, + /// Note value in credits (cached from note.value().inner()) + pub value: u64, +} + +/// Per-wallet shielded state, initialized lazily when the shielded tab is first opened. +/// +/// Manual `Debug` impl because `ClientCommitmentTree` does not implement `Debug`. +pub struct ShieldedWalletState { + /// ZIP32-derived Orchard keys (requires wallet to be unlocked) + pub keys: OrchardKeySet, + /// All tracked shielded notes + pub notes: Vec, + /// Client-side Sinsemilla commitment tree for witness generation + pub commitment_tree: ClientCommitmentTree, + /// Last note global position synced from platform + pub last_synced_index: u64, + /// Sum of unspent note values (cached) + pub shielded_balance: u64, +} + +impl std::fmt::Debug for ShieldedWalletState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ShieldedWalletState") + .field("notes_count", &self.notes.len()) + .field("last_synced_index", &self.last_synced_index) + .field("shielded_balance", &self.shielded_balance) + .finish_non_exhaustive() + } +} + +impl ShieldedWalletState { + /// Create a new shielded wallet state from ZIP32-derived keys. + pub fn new(keys: OrchardKeySet) -> Self { + Self { + keys, + notes: Vec::new(), + commitment_tree: ClientCommitmentTree::new(100), + last_synced_index: 0, + shielded_balance: 0, + } + } + + /// Recalculate the cached shielded balance from unspent notes. + pub fn recalculate_balance(&mut self) { + self.shielded_balance = self + .notes + .iter() + .filter(|n| !n.is_spent) + .map(|n| n.value) + .sum(); + } + + /// Get unspent notes. + pub fn unspent_notes(&self) -> Vec<&ShieldedNote> { + self.notes.iter().filter(|n| !n.is_spent).collect() + } +} + +/// Note serialization format (115 bytes): +/// `recipient(43) || value(8 LE) || rho(32) || rseed(32)` +const SERIALIZED_NOTE_LEN: usize = 43 + 8 + 32 + 32; + +/// Serialize an Orchard Note to 115 bytes for database persistence. +pub fn serialize_note(note: &Note) -> Vec { + let mut data = Vec::with_capacity(SERIALIZED_NOTE_LEN); + data.extend_from_slice(¬e.recipient().to_raw_address_bytes()); + data.extend_from_slice(¬e.value().inner().to_le_bytes()); + data.extend_from_slice(¬e.rho().to_bytes()); + data.extend_from_slice(note.rseed().as_bytes()); + data +} + +/// Deserialize an Orchard Note from 115 bytes. +pub fn deserialize_note(data: &[u8]) -> Option { + if data.len() != SERIALIZED_NOTE_LEN { + return None; + } + + let recipient_bytes: [u8; 43] = data[0..43].try_into().ok()?; + let recipient = PaymentAddress::from_raw_address_bytes(&recipient_bytes).into_option()?; + + let value_bytes: [u8; 8] = data[43..51].try_into().ok()?; + let value = NoteValue::from_raw(u64::from_le_bytes(value_bytes)); + + let rho_bytes: [u8; 32] = data[51..83].try_into().ok()?; + let rho = Rho::from_bytes(&rho_bytes).into_option()?; + + let rseed_bytes: [u8; 32] = data[83..115].try_into().ok()?; + let rseed = RandomSeed::from_bytes(rseed_bytes, &rho).into_option()?; + + Note::from_parts(recipient, value, rho, rseed).into_option() +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 152be79fd..8b7e9494d 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -6,6 +6,7 @@ use crate::model::qualified_identity::encrypted_key_storage::{ PrivateKeyData, WalletDerivationPath, }; use crate::model::wallet::Wallet; +use crate::model::wallet::WalletSeedHash; use crate::model::wallet::single_key::SingleKeyWallet; use crate::ui::contracts_documents::contracts_documents_screen::DocumentQueryScreen; use crate::ui::contracts_documents::document_action_screen::{ @@ -76,6 +77,9 @@ use tokens::unfreeze_tokens_screen::UnfreezeTokensScreen; use tokens::update_token_config::UpdateTokenConfigScreen; use tools::transition_visualizer_screen::TransitionVisualizerScreen; use wallets::add_new_wallet_screen::AddNewWalletScreen; +use wallets::shield_credits_screen::ShieldCreditsScreen; +use wallets::shielded_send_screen::ShieldedSendScreen; +use wallets::unshield_credits_screen::UnshieldCreditsScreen; pub mod components; pub mod contracts_documents; @@ -301,6 +305,11 @@ pub enum ScreenType { AssetLockDetail([u8; 32], usize), CreateAssetLock(Arc>), + // Shielded screens + ShieldCreditsScreen(WalletSeedHash), + ShieldedSendScreen(WalletSeedHash), + UnshieldCreditsScreen(WalletSeedHash), + // DashPay Screens DashPayContacts, DashPayProfile, @@ -416,6 +425,10 @@ impl PartialEq for ScreenType { ) => a1 == b1 && a2 == b2, (ScreenType::DashPayQRGenerator, ScreenType::DashPayQRGenerator) => true, (ScreenType::DashPayProfileSearch, ScreenType::DashPayProfileSearch) => true, + // Shielded screens + (ScreenType::ShieldCreditsScreen(_), ScreenType::ShieldCreditsScreen(_)) => true, + (ScreenType::ShieldedSendScreen(_), ScreenType::ShieldedSendScreen(_)) => true, + (ScreenType::UnshieldCreditsScreen(_), ScreenType::UnshieldCreditsScreen(_)) => true, _ => false, } } @@ -671,6 +684,16 @@ impl ScreenType { ScreenType::DashPayProfileSearch => { Screen::DashPayProfileSearchScreen(ProfileSearchScreen::new(app_context.clone())) } + // Shielded screens + ScreenType::ShieldCreditsScreen(seed_hash) => { + Screen::ShieldCreditsScreen(ShieldCreditsScreen::new(*seed_hash, app_context)) + } + ScreenType::ShieldedSendScreen(seed_hash) => { + Screen::ShieldedSendScreen(ShieldedSendScreen::new(*seed_hash, app_context)) + } + ScreenType::UnshieldCreditsScreen(seed_hash) => { + Screen::UnshieldCreditsScreen(UnshieldCreditsScreen::new(*seed_hash, app_context)) + } } } } @@ -729,6 +752,11 @@ pub enum Screen { AssetLockDetailScreen(AssetLockDetailScreen), CreateAssetLockScreen(CreateAssetLockScreen), + // Shielded Screens + ShieldCreditsScreen(ShieldCreditsScreen), + ShieldedSendScreen(ShieldedSendScreen), + UnshieldCreditsScreen(UnshieldCreditsScreen), + // DashPay Screens DashPayScreen(DashPayScreen), DashPayAddContactScreen(AddContactScreen), @@ -822,6 +850,10 @@ impl Screen { 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::ShieldCreditsScreen(screen) => screen.app_context = app_context.clone(), + Screen::ShieldedSendScreen(screen) => screen.app_context = app_context.clone(), + Screen::UnshieldCreditsScreen(screen) => screen.app_context = app_context.clone(), } } } @@ -1020,6 +1052,10 @@ impl Screen { } Screen::DashPayQRGeneratorScreen(_) => ScreenType::DashPayQRGenerator, Screen::DashPayProfileSearchScreen(_) => ScreenType::DashPayProfileSearch, + // Shielded screens + Screen::ShieldCreditsScreen(s) => ScreenType::ShieldCreditsScreen(s.seed_hash), + Screen::ShieldedSendScreen(s) => ScreenType::ShieldedSendScreen(s.seed_hash), + Screen::UnshieldCreditsScreen(s) => ScreenType::UnshieldCreditsScreen(s.seed_hash), } } } @@ -1088,6 +1124,10 @@ impl ScreenLike for Screen { Screen::DashPayContactInfoEditorScreen(screen) => screen.refresh(), Screen::DashPayQRGeneratorScreen(_) => {} Screen::DashPayProfileSearchScreen(screen) => screen.refresh(), + // Shielded screens + Screen::ShieldCreditsScreen(_) => {} + Screen::ShieldedSendScreen(_) => {} + Screen::UnshieldCreditsScreen(_) => {} } } @@ -1154,6 +1194,10 @@ impl ScreenLike for Screen { Screen::DashPayContactInfoEditorScreen(screen) => screen.refresh_on_arrival(), Screen::DashPayQRGeneratorScreen(_) => {} Screen::DashPayProfileSearchScreen(screen) => screen.refresh_on_arrival(), + // Shielded screens + Screen::ShieldCreditsScreen(_) => {} + Screen::ShieldedSendScreen(_) => {} + Screen::UnshieldCreditsScreen(_) => {} } } @@ -1220,6 +1264,10 @@ impl ScreenLike for Screen { Screen::DashPayContactInfoEditorScreen(screen) => screen.ui(ctx), Screen::DashPayQRGeneratorScreen(screen) => screen.ui(ctx), Screen::DashPayProfileSearchScreen(screen) => screen.ui(ctx), + // Shielded screens + Screen::ShieldCreditsScreen(screen) => screen.ui(ctx), + Screen::ShieldedSendScreen(screen) => screen.ui(ctx), + Screen::UnshieldCreditsScreen(screen) => screen.ui(ctx), } } @@ -1320,6 +1368,10 @@ impl ScreenLike for Screen { Screen::DashPayProfileSearchScreen(screen) => { screen.display_message(message, message_type) } + // Shielded screens + Screen::ShieldCreditsScreen(screen) => screen.display_message(message, message_type), + Screen::ShieldedSendScreen(screen) => screen.display_message(message, message_type), + Screen::UnshieldCreditsScreen(screen) => screen.display_message(message, message_type), } } @@ -1490,6 +1542,16 @@ impl ScreenLike for Screen { Screen::DashPayProfileSearchScreen(screen) => { screen.display_task_result(backend_task_success_result) } + // Shielded screens + Screen::ShieldCreditsScreen(screen) => { + screen.display_task_result(backend_task_success_result) + } + Screen::ShieldedSendScreen(screen) => { + screen.display_task_result(backend_task_success_result) + } + Screen::UnshieldCreditsScreen(screen) => { + screen.display_task_result(backend_task_success_result) + } } } @@ -1556,6 +1618,10 @@ impl ScreenLike for Screen { Screen::DashPayContactInfoEditorScreen(_) => {} Screen::DashPayQRGeneratorScreen(_) => {} Screen::DashPayProfileSearchScreen(_) => {} + // Shielded screens + Screen::ShieldCreditsScreen(_) => {} + Screen::ShieldedSendScreen(_) => {} + Screen::UnshieldCreditsScreen(_) => {} } } } diff --git a/src/ui/wallets/mod.rs b/src/ui/wallets/mod.rs index 61eb3b228..9a344895f 100644 --- a/src/ui/wallets/mod.rs +++ b/src/ui/wallets/mod.rs @@ -4,5 +4,9 @@ pub mod asset_lock_detail_screen; pub mod create_asset_lock_screen; pub mod import_mnemonic_screen; pub mod send_screen; +pub mod shield_credits_screen; +pub mod shielded_send_screen; +pub mod shielded_tab; pub mod single_key_send_screen; +pub mod unshield_credits_screen; pub mod wallets_screen; diff --git a/src/ui/wallets/shield_credits_screen.rs b/src/ui/wallets/shield_credits_screen.rs new file mode 100644 index 000000000..94a397485 --- /dev/null +++ b/src/ui/wallets/shield_credits_screen.rs @@ -0,0 +1,218 @@ +use crate::app::AppAction; +use crate::backend_task::shielded::ShieldedTask; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; +use crate::context::AppContext; +use crate::model::wallet::WalletSeedHash; +use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::styled::island_central_panel; +use crate::ui::components::top_panel::add_top_panel; +use crate::ui::{MessageType, RootScreenType, ScreenLike}; +use dash_sdk::dpp::address_funds::PlatformAddress; +use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; +use eframe::egui::{self, Context}; +use egui::{Color32, RichText}; +use std::sync::Arc; + +#[derive(PartialEq)] +enum Status { + NotStarted, + WaitingForResult, + Complete, +} + +pub struct ShieldCreditsScreen { + pub app_context: Arc, + pub seed_hash: WalletSeedHash, + amount_str: String, + from_address: Option, + status: Status, + error_message: Option, + success_message: Option, +} + +impl ShieldCreditsScreen { + pub fn new(seed_hash: WalletSeedHash, app_context: &Arc) -> Self { + // Try to find the first platform address from the wallet + let from_address = { + let wallets = app_context.wallets.read().unwrap(); + wallets.get(&seed_hash).and_then(|w| { + let wallet = w.read().unwrap(); + wallet + .platform_address_info + .keys() + .next() + .and_then(|addr| PlatformAddress::try_from(addr.clone()).ok()) + }) + }; + + Self { + app_context: app_context.clone(), + seed_hash, + amount_str: String::new(), + from_address, + status: Status::NotStarted, + error_message: None, + success_message: None, + } + } + + fn parse_amount_credits(&self) -> Option { + let trimmed = self.amount_str.trim(); + if trimmed.is_empty() { + return None; + } + // Try parsing as DASH amount first (has decimal point) + if trimmed.contains('.') { + let dash: f64 = trimmed.parse().ok()?; + if dash <= 0.0 { + return None; + } + Some((dash * CREDITS_PER_DUFF as f64 * 1e8) as u64) + } else { + // Parse as raw credits + let credits: u64 = trimmed.parse().ok()?; + if credits == 0 { + return None; + } + Some(credits) + } + } +} + +impl ScreenLike for ShieldCreditsScreen { + fn ui(&mut self, ctx: &Context) -> AppAction { + let mut action = add_top_panel( + ctx, + &self.app_context, + vec![ + ("Wallets", AppAction::PopScreen), + ("Shield Credits", AppAction::None), + ], + vec![], + ); + + action |= add_left_panel( + ctx, + &self.app_context, + RootScreenType::RootScreenWalletsBalances, + ); + + island_central_panel(ctx, |ui| { + ui.heading("Shield Credits"); + ui.add_space(10.0); + ui.label("Move credits from your platform identity into the shielded pool."); + ui.add_space(15.0); + + // Error/success messages + if let Some(err) = &self.error_message { + ui.colored_label(Color32::from_rgb(255, 100, 100), err); + ui.add_space(5.0); + } + if let Some(msg) = &self.success_message { + ui.colored_label(Color32::DARK_GREEN, msg); + ui.add_space(10.0); + if ui.button("Done").clicked() { + action = AppAction::PopScreen; + } + return; + } + + // Source address display + if let Some(addr) = &self.from_address { + ui.horizontal(|ui| { + ui.label("From platform address:"); + ui.monospace(format!("{}", addr)); + }); + ui.add_space(10.0); + } else { + ui.colored_label( + Color32::from_rgb(255, 100, 100), + "No platform address found. Register an identity first.", + ); + return; + } + + // Amount input + ui.horizontal(|ui| { + ui.label("Amount (DASH or credits):"); + ui.text_edit_singleline(&mut self.amount_str); + }); + ui.add_space(5.0); + + if let Some(credits) = self.parse_amount_credits() { + let dash = credits as f64 / CREDITS_PER_DUFF as f64 / 1e8; + ui.label(format!("= {:.8} DASH ({} credits)", dash, credits)); + } + + ui.add_space(15.0); + + // Confirm button + let can_confirm = + self.status == Status::NotStarted && self.parse_amount_credits().is_some(); + + if self.status == Status::WaitingForResult { + ui.horizontal(|ui| { + ui.add(egui::Spinner::new()); + ui.label("Shielding credits..."); + }); + } else { + ui.horizontal(|ui| { + if ui + .add_enabled( + can_confirm, + egui::Button::new( + RichText::new("Shield").color(Color32::WHITE).size(16.0), + ) + .fill(crate::ui::theme::DashColors::DASH_BLUE), + ) + .clicked() + && let (Some(amount), Some(addr)) = + (self.parse_amount_credits(), self.from_address) + { + self.status = Status::WaitingForResult; + self.error_message = None; + action = AppAction::BackendTask(BackendTask::ShieldedTask( + ShieldedTask::ShieldCredits { + seed_hash: self.seed_hash, + amount, + from_address: addr, + }, + )); + } + + ui.add_space(10.0); + if ui.button("Cancel").clicked() { + action = AppAction::PopScreen; + } + }); + } + }); + + action + } + + fn display_task_result(&mut self, result: BackendTaskSuccessResult) { + match result { + BackendTaskSuccessResult::ShieldedCreditsShielded { seed_hash, amount } + if seed_hash == self.seed_hash => + { + self.status = Status::Complete; + let dash = amount as f64 / CREDITS_PER_DUFF as f64 / 1e8; + self.success_message = Some(format!("Successfully shielded {:.8} DASH", dash)); + } + _ => {} + } + } + + fn display_message(&mut self, message: &str, message_type: MessageType) { + match message_type { + MessageType::Error => { + self.status = Status::NotStarted; + self.error_message = Some(message.to_string()); + } + _ => { + self.success_message = Some(message.to_string()); + } + } + } +} diff --git a/src/ui/wallets/shielded_send_screen.rs b/src/ui/wallets/shielded_send_screen.rs new file mode 100644 index 000000000..d00dfff40 --- /dev/null +++ b/src/ui/wallets/shielded_send_screen.rs @@ -0,0 +1,233 @@ +use crate::app::AppAction; +use crate::backend_task::shielded::ShieldedTask; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; +use crate::context::AppContext; +use crate::model::wallet::WalletSeedHash; +use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::styled::island_central_panel; +use crate::ui::components::top_panel::add_top_panel; +use crate::ui::{MessageType, RootScreenType, ScreenLike}; +use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; +use eframe::egui::{self, Context}; +use egui::{Color32, RichText}; +use std::sync::Arc; + +#[derive(PartialEq)] +enum Status { + NotStarted, + WaitingForResult, + Complete, +} + +pub struct ShieldedSendScreen { + pub app_context: Arc, + pub seed_hash: WalletSeedHash, + amount_str: String, + recipient_address_hex: String, + max_balance: u64, + status: Status, + error_message: Option, + success_message: Option, +} + +impl ShieldedSendScreen { + pub fn new(seed_hash: WalletSeedHash, app_context: &Arc) -> Self { + let max_balance = { + let states = app_context.shielded_states.lock().unwrap(); + states + .get(&seed_hash) + .map(|s| s.shielded_balance) + .unwrap_or(0) + }; + + Self { + app_context: app_context.clone(), + seed_hash, + amount_str: String::new(), + recipient_address_hex: String::new(), + max_balance, + status: Status::NotStarted, + error_message: None, + success_message: None, + } + } + + fn parse_amount_credits(&self) -> Option { + let trimmed = self.amount_str.trim(); + if trimmed.is_empty() { + return None; + } + if trimmed.contains('.') { + let dash: f64 = trimmed.parse().ok()?; + if dash <= 0.0 { + return None; + } + Some((dash * CREDITS_PER_DUFF as f64 * 1e8) as u64) + } else { + let credits: u64 = trimmed.parse().ok()?; + if credits == 0 { + return None; + } + Some(credits) + } + } + + fn validate_recipient(&self) -> Option> { + let trimmed = self.recipient_address_hex.trim(); + if trimmed.is_empty() { + return None; + } + let bytes = hex::decode(trimmed).ok()?; + if bytes.len() != 43 { + return None; + } + Some(bytes) + } +} + +impl ScreenLike for ShieldedSendScreen { + fn ui(&mut self, ctx: &Context) -> AppAction { + let mut action = add_top_panel( + ctx, + &self.app_context, + vec![ + ("Wallets", AppAction::PopScreen), + ("Send (Private)", AppAction::None), + ], + vec![], + ); + + action |= add_left_panel( + ctx, + &self.app_context, + RootScreenType::RootScreenWalletsBalances, + ); + + island_central_panel(ctx, |ui| { + ui.heading("Send (Private)"); + ui.add_space(10.0); + ui.label("Transfer credits privately within the shielded pool."); + ui.add_space(5.0); + + let dash_balance = self.max_balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; + ui.label(format!( + "Available shielded balance: {:.8} DASH", + dash_balance + )); + ui.add_space(15.0); + + // Error/success messages + if let Some(err) = &self.error_message { + ui.colored_label(Color32::from_rgb(255, 100, 100), err); + ui.add_space(5.0); + } + if let Some(msg) = &self.success_message { + ui.colored_label(Color32::DARK_GREEN, msg); + ui.add_space(10.0); + if ui.button("Done").clicked() { + action = AppAction::PopScreen; + } + return; + } + + // Recipient address input + ui.label("Recipient shielded address (hex, 43 bytes):"); + ui.add_space(2.0); + ui.text_edit_singleline(&mut self.recipient_address_hex); + if !self.recipient_address_hex.trim().is_empty() && self.validate_recipient().is_none() + { + ui.colored_label( + Color32::from_rgb(255, 100, 100), + "Invalid address (expected 86 hex chars = 43 bytes)", + ); + } + ui.add_space(10.0); + + // Amount input + ui.horizontal(|ui| { + ui.label("Amount (DASH or credits):"); + ui.text_edit_singleline(&mut self.amount_str); + }); + if let Some(credits) = self.parse_amount_credits() { + let dash = credits as f64 / CREDITS_PER_DUFF as f64 / 1e8; + ui.label(format!("= {:.8} DASH ({} credits)", dash, credits)); + if credits > self.max_balance { + ui.colored_label(Color32::from_rgb(255, 100, 100), "Exceeds shielded balance"); + } + } + ui.add_space(15.0); + + // Confirm + let amount_ok = self + .parse_amount_credits() + .is_some_and(|a| a <= self.max_balance); + let recipient_ok = self.validate_recipient().is_some(); + let can_confirm = self.status == Status::NotStarted && amount_ok && recipient_ok; + + if self.status == Status::WaitingForResult { + ui.horizontal(|ui| { + ui.add(egui::Spinner::new()); + ui.label("Sending privately..."); + }); + } else { + ui.horizontal(|ui| { + if ui + .add_enabled( + can_confirm, + egui::Button::new( + RichText::new("Send").color(Color32::WHITE).size(16.0), + ) + .fill(crate::ui::theme::DashColors::DASH_BLUE), + ) + .clicked() + && let (Some(amount), Some(recipient_bytes)) = + (self.parse_amount_credits(), self.validate_recipient()) + { + self.status = Status::WaitingForResult; + self.error_message = None; + action = AppAction::BackendTask(BackendTask::ShieldedTask( + ShieldedTask::ShieldedTransfer { + seed_hash: self.seed_hash, + amount, + recipient_address_bytes: recipient_bytes, + }, + )); + } + + ui.add_space(10.0); + if ui.button("Cancel").clicked() { + action = AppAction::PopScreen; + } + }); + } + }); + + action + } + + fn display_task_result(&mut self, result: BackendTaskSuccessResult) { + match result { + BackendTaskSuccessResult::ShieldedTransferComplete { seed_hash, amount } + if seed_hash == self.seed_hash => + { + self.status = Status::Complete; + let dash = amount as f64 / CREDITS_PER_DUFF as f64 / 1e8; + self.success_message = + Some(format!("Successfully sent {:.8} DASH privately", dash)); + } + _ => {} + } + } + + fn display_message(&mut self, message: &str, message_type: MessageType) { + match message_type { + MessageType::Error => { + self.status = Status::NotStarted; + self.error_message = Some(message.to_string()); + } + _ => { + self.success_message = Some(message.to_string()); + } + } + } +} diff --git a/src/ui/wallets/shielded_tab.rs b/src/ui/wallets/shielded_tab.rs new file mode 100644 index 000000000..f842c29de --- /dev/null +++ b/src/ui/wallets/shielded_tab.rs @@ -0,0 +1,473 @@ +use crate::app::AppAction; +use crate::backend_task::BackendTask; +use crate::backend_task::shielded::ShieldedTask; +use crate::context::AppContext; +use crate::model::wallet::WalletSeedHash; +use crate::ui::ScreenType; +use crate::ui::components::wallet_unlock_popup::{ + WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, +}; +use crate::ui::helpers::copy_text_to_clipboard; +use crate::ui::theme::DashColors; +use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; +use eframe::egui::{self, Ui}; +use egui::{Color32, Frame, Margin, RichText}; +use std::sync::Arc; + +/// View component for the Shielded tab within the Wallets screen. +pub struct ShieldedTabView { + app_context: Arc, + seed_hash: WalletSeedHash, + initializing: bool, + syncing: bool, + error_message: Option, + success_message: Option, + shielded_balance: u64, + is_initialized: bool, + /// Whether the commitment tree has been synced (enables spend operations). + tree_synced: bool, + /// Pending backend task to dispatch on next ui() call (e.g., auto-sync after init). + pending_task: Option, + /// Wallet unlock popup for the initialize flow. + wallet_unlock_popup: WalletUnlockPopup, +} + +impl ShieldedTabView { + pub fn new(app_context: &Arc, seed_hash: WalletSeedHash) -> Self { + Self { + app_context: app_context.clone(), + seed_hash, + initializing: false, + syncing: false, + error_message: None, + success_message: None, + shielded_balance: 0, + is_initialized: false, + tree_synced: false, + pending_task: None, + wallet_unlock_popup: WalletUnlockPopup::new(), + } + } + + pub fn update_seed_hash(&mut self, seed_hash: WalletSeedHash) { + if self.seed_hash != seed_hash { + self.seed_hash = seed_hash; + self.is_initialized = false; + self.tree_synced = false; + self.shielded_balance = 0; + self.error_message = None; + self.success_message = None; + } + } + + pub fn update_app_context(&mut self, app_context: &Arc) { + self.app_context = app_context.clone(); + } + + /// Handle backend task results for shielded operations. + pub fn handle_result( + &mut self, + result: &crate::backend_task::BackendTaskSuccessResult, + ) -> bool { + use crate::backend_task::BackendTaskSuccessResult; + match result { + BackendTaskSuccessResult::ShieldedInitialized { seed_hash, balance } + if *seed_hash == self.seed_hash => + { + self.initializing = false; + self.is_initialized = true; + self.shielded_balance = *balance; + // Auto-sync notes after initialization + self.syncing = true; + self.pending_task = Some(BackendTask::ShieldedTask(ShieldedTask::SyncNotes { + seed_hash: self.seed_hash, + })); + true + } + BackendTaskSuccessResult::ShieldedNotesSynced { + seed_hash, + new_notes, + balance, + } if *seed_hash == self.seed_hash => { + self.syncing = false; + self.tree_synced = true; + self.shielded_balance = *balance; + if *new_notes > 0 { + self.success_message = Some(format!("Synced {} new note(s)", new_notes)); + } else { + self.success_message = Some("Notes are up to date".to_string()); + } + true + } + BackendTaskSuccessResult::ShieldedCreditsShielded { seed_hash, amount } + if *seed_hash == self.seed_hash => + { + self.success_message = + Some(format!("Shielded {} successfully", format_credits(*amount))); + true + } + BackendTaskSuccessResult::ShieldedTransferComplete { seed_hash, amount } + if *seed_hash == self.seed_hash => + { + self.success_message = + Some(format!("Transferred {} privately", format_credits(*amount))); + true + } + BackendTaskSuccessResult::ShieldedCreditsUnshielded { seed_hash, amount } + if *seed_hash == self.seed_hash => + { + self.success_message = Some(format!("Unshielded {}", format_credits(*amount))); + true + } + BackendTaskSuccessResult::ShieldedNullifiersChecked { + seed_hash, + spent_count, + } if *seed_hash == self.seed_hash => { + if *spent_count > 0 { + self.success_message = Some(format!("Detected {} spent note(s)", spent_count)); + } + true + } + _ => false, + } + } + + pub fn handle_error(&mut self, error: &str) { + self.syncing = false; + self.initializing = false; + self.error_message = Some(error.to_string()); + } + + /// Render the shielded tab content. + pub fn ui(&mut self, ui: &mut Ui) -> AppAction { + let dark_mode = ui.ctx().style().visuals.dark_mode; + let mut action = self + .pending_task + .take() + .map(AppAction::BackendTask) + .unwrap_or(AppAction::None); + + // Messages + if let Some(err) = &self.error_message.clone() { + Frame::new() + .fill(Color32::from_rgb(255, 100, 100).gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(err).color(Color32::from_rgb(255, 100, 100))); + if ui.small_button("Dismiss").clicked() { + self.error_message = None; + } + }); + }); + ui.add_space(5.0); + } + + if let Some(msg) = &self.success_message.clone() { + Frame::new() + .fill(Color32::DARK_GREEN.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(msg).color(Color32::DARK_GREEN)); + if ui.small_button("Dismiss").clicked() { + self.success_message = None; + } + }); + }); + ui.add_space(5.0); + } + + // --- Not yet initialized: show Initialize button + unlock popup --- + if !self.is_initialized { + if self.initializing { + ui.horizontal(|ui| { + ui.add(egui::Spinner::new().color(DashColors::DASH_BLUE)); + ui.label("Initializing shielded wallet (deriving ZIP32 keys)..."); + }); + } else { + ui.add_space(20.0); + ui.label( + RichText::new( + "Initialize your shielded wallet to enable private transactions.", + ) + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(10.0); + + let init_btn = egui::Button::new( + RichText::new("Initialize Shielded Wallet") + .color(Color32::WHITE) + .size(16.0), + ) + .fill(DashColors::DASH_BLUE); + + if ui.add(init_btn).clicked() { + // Get the wallet Arc + let wallet_arc = { + let wallets = self.app_context.wallets.read().unwrap(); + wallets.get(&self.seed_hash).cloned() + }; + + if let Some(wallet) = &wallet_arc { + if wallet_needs_unlock(wallet) { + // Wallet is locked — open unlock popup + self.wallet_unlock_popup.open(); + } else { + // Try open without password (for passwordless wallets) + let _ = try_open_wallet_no_password(wallet); + // Proceed to initialize + self.initializing = true; + action |= AppAction::BackendTask(BackendTask::ShieldedTask( + ShieldedTask::InitializeShieldedWallet { + seed_hash: self.seed_hash, + }, + )); + } + } + } + } + + // Show unlock popup if open + if self.wallet_unlock_popup.is_open() { + let wallet_arc = { + let wallets = self.app_context.wallets.read().unwrap(); + wallets.get(&self.seed_hash).cloned() + }; + + if let Some(wallet) = &wallet_arc { + let unlock_result = + self.wallet_unlock_popup + .show(ui.ctx(), wallet, &self.app_context); + match unlock_result { + WalletUnlockResult::Unlocked => { + // Wallet is now open — proceed to initialize + self.initializing = true; + action |= AppAction::BackendTask(BackendTask::ShieldedTask( + ShieldedTask::InitializeShieldedWallet { + seed_hash: self.seed_hash, + }, + )); + } + WalletUnlockResult::Cancelled => { + // User cancelled — do nothing + } + WalletUnlockResult::Pending => { + // Still showing popup + } + } + } + } + + return action; + } + + // --- Initialized: show balance, address, actions --- + + // Balance display + ui.add_space(10.0); + Frame::new() + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(16, 12)) + .corner_radius(8.0) + .show(ui, |ui| { + ui.label( + RichText::new("Shielded Balance") + .size(16.0) + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label( + RichText::new(format_credits(self.shielded_balance)) + .size(28.0) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + }); + }); + + ui.add_space(10.0); + + // Payment address + let address_str = { + let states = self.app_context.shielded_states.lock().unwrap(); + states.get(&self.seed_hash).map(|state| { + let raw = state.keys.default_address.to_raw_address_bytes(); + hex::encode(raw) + }) + }; + + if let Some(addr) = &address_str { + Frame::new() + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(16, 12)) + .corner_radius(8.0) + .show(ui, |ui| { + ui.label( + RichText::new("Shielded Payment Address") + .size(14.0) + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(4.0); + ui.horizontal(|ui| { + let truncated = if addr.len() > 20 { + format!("{}...{}", &addr[..10], &addr[addr.len() - 10..]) + } else { + addr.clone() + }; + ui.monospace(&truncated); + if ui.small_button("Copy").clicked() { + let _ = copy_text_to_clipboard(addr); + } + }); + }); + } + + ui.add_space(10.0); + + // Action buttons + ui.horizontal(|ui| { + let shield_btn = + egui::Button::new(RichText::new("Shield").color(Color32::WHITE).size(14.0)) + .fill(DashColors::DASH_BLUE); + if ui + .add_enabled(!self.syncing, shield_btn) + .on_hover_text("Shield credits from platform address into the shielded pool") + .clicked() + { + action |= AppAction::AddScreen( + ScreenType::ShieldCreditsScreen(self.seed_hash) + .create_screen(&self.app_context), + ); + } + + let can_spend = !self.syncing && self.tree_synced && self.shielded_balance > 0; + + let send_btn = egui::Button::new( + RichText::new("Send (Private)") + .color(Color32::WHITE) + .size(14.0), + ) + .fill(DashColors::DASH_BLUE); + if ui + .add_enabled(can_spend, send_btn) + .on_hover_text(if self.tree_synced { + "Transfer privately within the shielded pool" + } else { + "Sync notes first to enable spending" + }) + .clicked() + { + action |= AppAction::AddScreen( + ScreenType::ShieldedSendScreen(self.seed_hash).create_screen(&self.app_context), + ); + } + + let unshield_btn = + egui::Button::new(RichText::new("Unshield").color(Color32::WHITE).size(14.0)) + .fill(DashColors::DASH_BLUE); + if ui + .add_enabled(can_spend, unshield_btn) + .on_hover_text(if self.tree_synced { + "Unshield credits to a platform address" + } else { + "Sync notes first to enable spending" + }) + .clicked() + { + action |= AppAction::AddScreen( + ScreenType::UnshieldCreditsScreen(self.seed_hash) + .create_screen(&self.app_context), + ); + } + + ui.add_space(10.0); + + if self.syncing { + ui.add(egui::Spinner::new().color(DashColors::DASH_BLUE)); + ui.label("Syncing..."); + } else if ui.button("Sync Notes").clicked() { + self.syncing = true; + self.success_message = None; + self.error_message = None; + action |= + AppAction::BackendTask(BackendTask::ShieldedTask(ShieldedTask::SyncNotes { + seed_hash: self.seed_hash, + })); + } + }); + + ui.add_space(15.0); + + // Notes table + let notes_info: Vec<(u64, u64, bool)> = { + let states = self.app_context.shielded_states.lock().unwrap(); + states + .get(&self.seed_hash) + .map(|state| { + state + .notes + .iter() + .map(|n| (n.value, n.block_height, n.is_spent)) + .collect() + }) + .unwrap_or_default() + }; + + if !notes_info.is_empty() { + ui.label( + RichText::new("Shielded Notes") + .size(16.0) + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(5.0); + + egui::Grid::new("shielded_notes_grid") + .num_columns(3) + .striped(true) + .spacing([20.0, 4.0]) + .show(ui, |ui| { + ui.label(RichText::new("Value").strong()); + ui.label(RichText::new("Block").strong()); + ui.label(RichText::new("Status").strong()); + ui.end_row(); + + for (value, height, is_spent) in ¬es_info { + ui.label(format_credits(*value)); + ui.label(if *height > 0 { + height.to_string() + } else { + "-".to_string() + }); + if *is_spent { + ui.label( + RichText::new("Spent").color(DashColors::text_secondary(dark_mode)), + ); + } else { + ui.label(RichText::new("Unspent").color(Color32::DARK_GREEN)); + } + ui.end_row(); + } + }); + } else { + ui.label( + RichText::new("No shielded notes yet. Shield some credits to get started.") + .color(DashColors::text_secondary(dark_mode)), + ); + } + + action + } +} + +fn format_credits(credits: u64) -> String { + let dash = credits as f64 / CREDITS_PER_DUFF as f64 / 1e8; + if dash >= 0.01 { + format!("{:.4} DASH", dash) + } else { + format!("{} credits", credits) + } +} diff --git a/src/ui/wallets/unshield_credits_screen.rs b/src/ui/wallets/unshield_credits_screen.rs new file mode 100644 index 000000000..3b86a6200 --- /dev/null +++ b/src/ui/wallets/unshield_credits_screen.rs @@ -0,0 +1,237 @@ +use crate::app::AppAction; +use crate::backend_task::shielded::ShieldedTask; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; +use crate::context::AppContext; +use crate::model::wallet::WalletSeedHash; +use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::styled::island_central_panel; +use crate::ui::components::top_panel::add_top_panel; +use crate::ui::{MessageType, RootScreenType, ScreenLike}; +use dash_sdk::dpp::address_funds::PlatformAddress; +use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; +use eframe::egui::{self, Context}; +use egui::{Color32, RichText}; +use std::sync::Arc; + +#[derive(PartialEq)] +enum Status { + NotStarted, + WaitingForResult, + Complete, +} + +pub struct UnshieldCreditsScreen { + pub app_context: Arc, + pub seed_hash: WalletSeedHash, + amount_str: String, + to_platform_address: Option, + max_balance: u64, + status: Status, + error_message: Option, + success_message: Option, +} + +impl UnshieldCreditsScreen { + pub fn new(seed_hash: WalletSeedHash, app_context: &Arc) -> Self { + let max_balance = { + let states = app_context.shielded_states.lock().unwrap(); + states + .get(&seed_hash) + .map(|s| s.shielded_balance) + .unwrap_or(0) + }; + + // Try to find the first platform address from the wallet + let to_platform_address = { + let wallets = app_context.wallets.read().unwrap(); + wallets.get(&seed_hash).and_then(|w| { + let wallet = w.read().unwrap(); + wallet + .platform_address_info + .keys() + .next() + .and_then(|addr| PlatformAddress::try_from(addr.clone()).ok()) + }) + }; + + Self { + app_context: app_context.clone(), + seed_hash, + amount_str: String::new(), + to_platform_address, + max_balance, + status: Status::NotStarted, + error_message: None, + success_message: None, + } + } + + fn parse_amount_credits(&self) -> Option { + let trimmed = self.amount_str.trim(); + if trimmed.is_empty() { + return None; + } + if trimmed.contains('.') { + let dash: f64 = trimmed.parse().ok()?; + if dash <= 0.0 { + return None; + } + Some((dash * CREDITS_PER_DUFF as f64 * 1e8) as u64) + } else { + let credits: u64 = trimmed.parse().ok()?; + if credits == 0 { + return None; + } + Some(credits) + } + } +} + +impl ScreenLike for UnshieldCreditsScreen { + fn ui(&mut self, ctx: &Context) -> AppAction { + let mut action = add_top_panel( + ctx, + &self.app_context, + vec![ + ("Wallets", AppAction::PopScreen), + ("Unshield Credits", AppAction::None), + ], + vec![], + ); + + action |= add_left_panel( + ctx, + &self.app_context, + RootScreenType::RootScreenWalletsBalances, + ); + + island_central_panel(ctx, |ui| { + ui.heading("Unshield Credits"); + ui.add_space(10.0); + ui.label("Move credits from the shielded pool back to a platform address."); + ui.add_space(5.0); + + let dash_balance = self.max_balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; + ui.label(format!( + "Available shielded balance: {:.8} DASH", + dash_balance + )); + ui.add_space(15.0); + + // Error/success messages + if let Some(err) = &self.error_message { + ui.colored_label(Color32::from_rgb(255, 100, 100), err); + ui.add_space(5.0); + } + if let Some(msg) = &self.success_message { + ui.colored_label(Color32::DARK_GREEN, msg); + ui.add_space(10.0); + if ui.button("Done").clicked() { + action = AppAction::PopScreen; + } + return; + } + + // Destination address display + if let Some(addr) = &self.to_platform_address { + ui.horizontal(|ui| { + ui.label("To platform address:"); + ui.monospace(format!("{}", addr)); + }); + ui.add_space(10.0); + } else { + ui.colored_label( + Color32::from_rgb(255, 100, 100), + "No platform address found. Register an identity first.", + ); + return; + } + + // Amount input + ui.horizontal(|ui| { + ui.label("Amount (DASH or credits):"); + ui.text_edit_singleline(&mut self.amount_str); + }); + if let Some(credits) = self.parse_amount_credits() { + let dash = credits as f64 / CREDITS_PER_DUFF as f64 / 1e8; + ui.label(format!("= {:.8} DASH ({} credits)", dash, credits)); + if credits > self.max_balance { + ui.colored_label(Color32::from_rgb(255, 100, 100), "Exceeds shielded balance"); + } + } + ui.add_space(15.0); + + // Confirm + let amount_ok = self + .parse_amount_credits() + .is_some_and(|a| a <= self.max_balance); + let can_confirm = self.status == Status::NotStarted + && amount_ok + && self.to_platform_address.is_some(); + + if self.status == Status::WaitingForResult { + ui.horizontal(|ui| { + ui.add(egui::Spinner::new()); + ui.label("Unshielding credits..."); + }); + } else { + ui.horizontal(|ui| { + if ui + .add_enabled( + can_confirm, + egui::Button::new( + RichText::new("Unshield").color(Color32::WHITE).size(16.0), + ) + .fill(crate::ui::theme::DashColors::DASH_BLUE), + ) + .clicked() + && let (Some(amount), Some(addr)) = + (self.parse_amount_credits(), self.to_platform_address) + { + self.status = Status::WaitingForResult; + self.error_message = None; + action = AppAction::BackendTask(BackendTask::ShieldedTask( + ShieldedTask::UnshieldCredits { + seed_hash: self.seed_hash, + amount, + to_platform_address: addr, + }, + )); + } + + ui.add_space(10.0); + if ui.button("Cancel").clicked() { + action = AppAction::PopScreen; + } + }); + } + }); + + action + } + + fn display_task_result(&mut self, result: BackendTaskSuccessResult) { + match result { + BackendTaskSuccessResult::ShieldedCreditsUnshielded { seed_hash, amount } + if seed_hash == self.seed_hash => + { + self.status = Status::Complete; + let dash = amount as f64 / CREDITS_PER_DUFF as f64 / 1e8; + self.success_message = Some(format!("Successfully unshielded {:.8} DASH", dash)); + } + _ => {} + } + } + + fn display_message(&mut self, message: &str, message_type: MessageType) { + match message_type { + MessageType::Error => { + self.status = Status::NotStarted; + self.error_message = Some(message.to_string()); + } + _ => { + self.success_message = Some(message.to_string()); + } + } + } +} diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 4af4817ff..d5a2550fc 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -31,11 +31,20 @@ use egui_extras::{Column, TableBuilder}; use std::sync::{Arc, RwLock}; use crate::model::wallet::single_key::SingleKeyWallet; +use crate::ui::wallets::shielded_tab::ShieldedTabView; use address_table::{SortColumn, SortOrder}; use dialogs::{ FundPlatformAddressDialogState, PrivateKeyDialogState, ReceiveDialogState, SendDialogState, }; +/// Tab selector for the wallet detail panel. +#[derive(Default, Clone, Copy, PartialEq)] +enum WalletViewTab { + #[default] + Balances, + Shielded, +} + /// Refresh mode for dev mode dropdown - controls what gets refreshed #[derive(Clone, Copy, PartialEq, Eq, Default)] enum RefreshMode { @@ -113,6 +122,10 @@ pub struct WalletsBalancesScreen { utxo_page: usize, /// Selected refresh mode (only shown in dev mode) refresh_mode: RefreshMode, + /// Currently selected tab in the wallet detail panel + selected_tab: WalletViewTab, + /// Shielded tab view component (lazily initialized per wallet) + shielded_tab_view: Option, } impl WalletsBalancesScreen { @@ -199,6 +212,8 @@ impl WalletsBalancesScreen { pending_asset_lock_search_after_unlock: false, utxo_page: 0, refresh_mode: RefreshMode::default(), + selected_tab: WalletViewTab::default(), + shielded_tab_view: None, } } @@ -226,6 +241,8 @@ impl WalletsBalancesScreen { self.selected_wallet = Some(wallet.clone()); self.selected_single_key_wallet = None; self.selected_account = None; + self.selected_tab = WalletViewTab::default(); + self.shielded_tab_view = None; if let Ok(hash) = wallet.read().map(|g| g.seed_hash()) { self.persist_selected_wallet_hash(Some(hash)); @@ -1130,46 +1147,102 @@ impl WalletsBalancesScreen { ); }); - let summaries = { - let wallet = wallet_arc.read().unwrap(); - self.render_wallet_overview(ui, &wallet); - collect_account_summaries(&wallet) - }; + // Tab bar: Balances | Shielded + ui.add_space(6.0); + ui.horizontal(|ui| { + let balances_text = if self.selected_tab == WalletViewTab::Balances { + RichText::new("Balances") + .strong() + .color(DashColors::DASH_BLUE) + } else { + RichText::new("Balances") + .color(DashColors::text_secondary(dark_mode)) + }; + if ui + .selectable_label( + self.selected_tab == WalletViewTab::Balances, + balances_text, + ) + .clicked() + { + self.selected_tab = WalletViewTab::Balances; + } - self.ensure_account_selection(&summaries); - action |= self.render_action_buttons(ui, ctx); - ui.add_space(10.0); - ui.separator(); - self.render_accounts_section(ui, &summaries); - ui.add_space(10.0); + let shielded_text = if self.selected_tab == WalletViewTab::Shielded { + RichText::new("Shielded") + .strong() + .color(DashColors::DASH_BLUE) + } else { + RichText::new("Shielded") + .color(DashColors::text_secondary(dark_mode)) + }; + if ui + .selectable_label( + self.selected_tab == WalletViewTab::Shielded, + shielded_text, + ) + .clicked() + { + self.selected_tab = WalletViewTab::Shielded; + } + }); ui.separator(); - ui.add_space(10.0); - let addresses_heading = self - .selected_account - .as_ref() - .map(|(category, index)| { - format!("Addresses ({})", category.label(*index)) - }) - .unwrap_or_else(|| "Addresses".to_string()); - ui.heading( - RichText::new(addresses_heading) - .color(DashColors::text_primary(dark_mode)), - ); - ui.add_space(8.0); - action |= self.render_address_table(ui); + ui.add_space(4.0); + + match self.selected_tab { + WalletViewTab::Balances => { + let summaries = { + let wallet = wallet_arc.read().unwrap(); + self.render_wallet_overview(ui, &wallet); + collect_account_summaries(&wallet) + }; - // Transactions section - requires SPV which is dev mode only - if self.app_context.is_developer_mode() { - ui.add_space(10.0); - ui.separator(); - self.render_transactions_section(ui); - } + self.ensure_account_selection(&summaries); + action |= self.render_action_buttons(ui, ctx); + ui.add_space(10.0); + ui.separator(); + self.render_accounts_section(ui, &summaries); + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + let addresses_heading = self + .selected_account + .as_ref() + .map(|(category, index)| { + format!("Addresses ({})", category.label(*index)) + }) + .unwrap_or_else(|| "Addresses".to_string()); + ui.heading( + RichText::new(addresses_heading) + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(8.0); + action |= self.render_address_table(ui); + + // Transactions section - requires SPV which is dev mode only + if self.app_context.is_developer_mode() { + ui.add_space(10.0); + ui.separator(); + self.render_transactions_section(ui); + } - ui.add_space(14.0); - self.render_bottom_options(ui); + ui.add_space(14.0); + self.render_bottom_options(ui); - ui.add_space(16.0); - action |= self.render_wallet_asset_locks(ui); + ui.add_space(16.0); + action |= self.render_wallet_asset_locks(ui); + } + WalletViewTab::Shielded => { + let seed_hash = wallet_arc.read().unwrap().seed_hash(); + let shielded_view = + self.shielded_tab_view.get_or_insert_with(|| { + ShieldedTabView::new(&self.app_context, seed_hash) + }); + shielded_view.update_seed_hash(seed_hash); + shielded_view.update_app_context(&self.app_context); + action |= shielded_view.ui(ui); + } + } }); }); }); @@ -1775,6 +1848,11 @@ impl ScreenLike for WalletsBalancesScreen { self.fund_platform_dialog.status_is_error = true; return; } + + // Forward errors to the shielded tab view so it can reset spinner states + if let Some(shielded_view) = &mut self.shielded_tab_view { + shielded_view.handle_error(message); + } } self.set_message(message.to_string(), message_type); } @@ -1900,6 +1978,17 @@ impl ScreenLike for WalletsBalancesScreen { self.refreshing = false; self.display_message(&msg, MessageType::Success); } + // Shielded pool results + result @ (crate::ui::BackendTaskSuccessResult::ShieldedInitialized { .. } + | crate::ui::BackendTaskSuccessResult::ShieldedNotesSynced { .. } + | crate::ui::BackendTaskSuccessResult::ShieldedCreditsShielded { .. } + | crate::ui::BackendTaskSuccessResult::ShieldedTransferComplete { .. } + | crate::ui::BackendTaskSuccessResult::ShieldedCreditsUnshielded { .. } + | crate::ui::BackendTaskSuccessResult::ShieldedNullifiersChecked { .. }) => { + if let Some(shielded_view) = &mut self.shielded_tab_view { + shielded_view.handle_result(&result); + } + } _ => {} } } From f8df452f2ff54aa7f09fa76248f8bdbff4161df5 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 24 Feb 2026 14:50:37 +0700 Subject: [PATCH 002/147] more work --- Cargo.lock | 475 ++++++++------ Cargo.toml | 21 + scripts/configure-local.sh | 63 ++ src/backend_task/core/mod.rs | 50 +- src/backend_task/mod.rs | 17 +- src/backend_task/platform_info.rs | 21 + src/backend_task/shielded/bundle.rs | 499 +++++++++++++-- src/backend_task/shielded/mod.rs | 16 + src/backend_task/shielded/nullifiers.rs | 85 +-- src/backend_task/shielded/sync.rs | 265 ++++---- .../wallet/fetch_platform_address_balances.rs | 521 +++------------ .../fund_platform_address_from_asset_lock.rs | 4 +- ...fund_platform_address_from_wallet_utxos.rs | 4 +- src/backend_task/wallet/mod.rs | 13 - .../wallet/withdraw_from_platform_address.rs | 4 +- src/context/shielded.rs | 258 +++++++- src/context/wallet_lifecycle.rs | 5 +- src/database/initialization.rs | 9 +- src/database/mod.rs | 25 +- src/database/shielded.rs | 139 ++-- src/database/wallet.rs | 145 ++--- src/model/wallet/asset_lock_transaction.rs | 43 +- src/model/wallet/mod.rs | 164 +++-- src/model/wallet/shielded.rs | 37 +- src/ui/helpers.rs | 18 + src/ui/identities/transfer_screen.rs | 4 +- src/ui/mod.rs | 24 + src/ui/network_chooser_screen.rs | 39 +- src/ui/tools/address_balance_screen.rs | 4 +- src/ui/tools/platform_info_screen.rs | 6 + src/ui/wallets/mod.rs | 1 + src/ui/wallets/send_screen.rs | 194 +++++- src/ui/wallets/shield_credits_screen.rs | 591 ++++++++++++++++-- .../wallets/shield_from_asset_lock_screen.rs | 214 +++++++ src/ui/wallets/shielded_send_screen.rs | 25 +- src/ui/wallets/shielded_tab.rs | 227 +++++-- src/ui/wallets/unshield_credits_screen.rs | 166 +++-- .../wallets/wallets_screen/address_table.rs | 132 ++-- src/ui/wallets/wallets_screen/dialogs.rs | 244 +++++++- src/ui/wallets/wallets_screen/mod.rs | 467 ++++++++++++-- 40 files changed, 3743 insertions(+), 1496 deletions(-) create mode 100755 scripts/configure-local.sh create mode 100644 src/ui/wallets/shield_from_asset_lock_screen.rs diff --git a/Cargo.lock b/Cargo.lock index 870e535f1..e91a64b7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -280,9 +280,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.101" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "arboard" @@ -581,7 +581,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -642,7 +642,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -722,9 +722,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.15.4" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" +checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9" dependencies = [ "aws-lc-sys", "zeroize", @@ -855,7 +855,7 @@ dependencies = [ "regex", "rustc-hash 1.1.0", "shlex", - "syn 2.0.116", + "syn 2.0.117", "which 4.4.2", ] @@ -1128,9 +1128,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.1" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "bytemuck" @@ -1149,7 +1149,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -1317,9 +1317,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.43" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "js-sys", @@ -1398,15 +1398,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "ckb-merkle-mountain-range" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "327c468ff2702c0a2b7ad26728a180611da22244882959b8b2e85c79bf56bcea" -dependencies = [ - "cfg-if", -] - [[package]] name = "clang-sys" version = "1.8.1" @@ -1420,9 +1411,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.59" +version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5caf74d17c3aec5495110c34cc3f78644bfa89af6c8993ed4de2790e49b6499" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" dependencies = [ "clap_builder", "clap_derive", @@ -1430,9 +1421,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.59" +version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "370daa45065b80218950227371916a1633217ae42b2715b2287b606dcd618e24" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" dependencies = [ "anstream", "anstyle", @@ -1449,7 +1440,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -1776,12 +1767,12 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] name = "dapi-grpc" -version = "3.0.1" +version = "3.1.0-dev.1" dependencies = [ "dash-platform-macros", "futures-core", @@ -1832,7 +1823,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -1843,12 +1834,12 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] name = "dash-context-provider" -version = "3.0.1" +version = "3.1.0-dev.1" dependencies = [ "dpp", "drive", @@ -1936,16 +1927,16 @@ dependencies = [ [[package]] name = "dash-platform-macros" -version = "3.0.1" +version = "3.1.0-dev.1" dependencies = [ "heck", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] name = "dash-sdk" -version = "3.0.1" +version = "3.1.0-dev.1" dependencies = [ "arc-swap", "async-trait", @@ -2096,7 +2087,7 @@ dependencies = [ [[package]] name = "dashpay-contract" -version = "3.0.1" +version = "3.1.0-dev.1" dependencies = [ "platform-value", "platform-version", @@ -2106,7 +2097,7 @@ dependencies = [ [[package]] name = "data-contracts" -version = "3.0.1" +version = "3.1.0-dev.1" dependencies = [ "dashpay-contract", "dpns-contract", @@ -2146,9 +2137,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.6" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", "serde_core", @@ -2183,7 +2174,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -2193,7 +2184,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -2222,7 +2213,7 @@ checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", "unicode-xid", ] @@ -2236,7 +2227,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -2317,7 +2308,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -2358,7 +2349,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "dpns-contract" -version = "3.0.1" +version = "3.1.0-dev.1" dependencies = [ "platform-value", "platform-version", @@ -2368,7 +2359,7 @@ dependencies = [ [[package]] name = "dpp" -version = "3.0.1" +version = "3.1.0-dev.1" dependencies = [ "anyhow", "async-trait", @@ -2409,24 +2400,24 @@ dependencies = [ "serde_json", "serde_repr", "sha2", - "strum", + "strum 0.26.3", "thiserror 2.0.18", "tracing", ] [[package]] name = "drive" -version = "3.0.1" +version = "3.1.0-dev.1" dependencies = [ "bincode 2.0.1", "byteorder", "derive_more 1.0.0", "dpp", - "grovedb 4.0.0", - "grovedb-costs 4.0.0", + "grovedb 4.0.0 (git+https://github.com/dashpay/grovedb?rev=679ea5a230a9d2e584ac2949ecac179a179a0444)", + "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=679ea5a230a9d2e584ac2949ecac179a179a0444)", "grovedb-epoch-based-storage-flags", - "grovedb-path 4.0.0", - "grovedb-version 4.0.0", + "grovedb-path 4.0.0 (git+https://github.com/dashpay/grovedb?rev=679ea5a230a9d2e584ac2949ecac179a179a0444)", + "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=679ea5a230a9d2e584ac2949ecac179a179a0444)", "hex", "indexmap 2.13.0", "integer-encoding", @@ -2440,7 +2431,7 @@ dependencies = [ [[package]] name = "drive-proof-verifier" -version = "3.0.1" +version = "3.1.0-dev.1" dependencies = [ "bincode 2.0.1", "dapi-grpc", @@ -2765,7 +2756,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -2785,7 +2776,7 @@ checksum = "685adfa4d6f3d765a26bc5dbc936577de9abf756c1feeb3089b01dd395034842" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -2805,7 +2796,7 @@ checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -2826,7 +2817,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -2837,7 +2828,7 @@ checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -3007,7 +2998,7 @@ checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -3021,7 +3012,7 @@ dependencies = [ [[package]] name = "feature-flags-contract" -version = "3.0.1" +version = "3.1.0-dev.1" dependencies = [ "platform-value", "platform-version", @@ -3143,7 +3134,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -3268,7 +3259,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -3381,7 +3372,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] @@ -3579,19 +3570,16 @@ dependencies = [ [[package]] name = "grovedb" version = "4.0.0" +source = "git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897#33dfd48a1718160cb333fa95424be491785f1897" dependencies = [ "bincode 2.0.1", "bincode_derive", "blake3", - "ckb-merkle-mountain-range", - "grovedb-bulk-append-tree", - "grovedb-costs 4.0.0", - "grovedb-dense-fixed-sized-merkle-tree", - "grovedb-element 4.0.0", - "grovedb-merk 4.0.0", - "grovedb-mmr", - "grovedb-path 4.0.0", - "grovedb-version 4.0.0", + "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", + "grovedb-element 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", + "grovedb-merk 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", + "grovedb-path 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", + "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", "hex", "hex-literal", "indexmap 2.13.0", @@ -3604,16 +3592,20 @@ dependencies = [ [[package]] name = "grovedb" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897#33dfd48a1718160cb333fa95424be491785f1897" +source = "git+https://github.com/dashpay/grovedb?rev=679ea5a230a9d2e584ac2949ecac179a179a0444#679ea5a230a9d2e584ac2949ecac179a179a0444" dependencies = [ "bincode 2.0.1", "bincode_derive", "blake3", - "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", - "grovedb-element 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", - "grovedb-merk 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", - "grovedb-path 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", - "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", + "grovedb-bulk-append-tree", + "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=679ea5a230a9d2e584ac2949ecac179a179a0444)", + "grovedb-dense-fixed-sized-merkle-tree", + "grovedb-element 4.0.0 (git+https://github.com/dashpay/grovedb?rev=679ea5a230a9d2e584ac2949ecac179a179a0444)", + "grovedb-merk 4.0.0 (git+https://github.com/dashpay/grovedb?rev=679ea5a230a9d2e584ac2949ecac179a179a0444)", + "grovedb-merkle-mountain-range", + "grovedb-path 4.0.0 (git+https://github.com/dashpay/grovedb?rev=679ea5a230a9d2e584ac2949ecac179a179a0444)", + "grovedb-query", + "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=679ea5a230a9d2e584ac2949ecac179a179a0444)", "hex", "hex-literal", "indexmap 2.13.0", @@ -3626,11 +3618,15 @@ dependencies = [ [[package]] name = "grovedb-bulk-append-tree" version = "4.0.0" +source = "git+https://github.com/dashpay/grovedb?rev=679ea5a230a9d2e584ac2949ecac179a179a0444#679ea5a230a9d2e584ac2949ecac179a179a0444" dependencies = [ "bincode 2.0.1", "blake3", + "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=679ea5a230a9d2e584ac2949ecac179a179a0444)", "grovedb-dense-fixed-sized-merkle-tree", - "grovedb-mmr", + "grovedb-merkle-mountain-range", + "grovedb-query", + "grovedb-storage", "hex", "thiserror 2.0.18", ] @@ -3638,17 +3634,19 @@ dependencies = [ [[package]] name = "grovedb-commitment-tree" version = "4.0.0" +source = "git+https://github.com/dashpay/grovedb?rev=679ea5a230a9d2e584ac2949ecac179a179a0444#679ea5a230a9d2e584ac2949ecac179a179a0444" dependencies = [ "incrementalmerkletree", "orchard", + "rusqlite", "shardtree", "thiserror 2.0.18", - "zcash_note_encryption", ] [[package]] name = "grovedb-costs" version = "4.0.0" +source = "git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897#33dfd48a1718160cb333fa95424be491785f1897" dependencies = [ "integer-encoding", "intmap", @@ -3658,7 +3656,7 @@ dependencies = [ [[package]] name = "grovedb-costs" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897#33dfd48a1718160cb333fa95424be491785f1897" +source = "git+https://github.com/dashpay/grovedb?rev=679ea5a230a9d2e584ac2949ecac179a179a0444#679ea5a230a9d2e584ac2949ecac179a179a0444" dependencies = [ "integer-encoding", "intmap", @@ -3668,20 +3666,25 @@ dependencies = [ [[package]] name = "grovedb-dense-fixed-sized-merkle-tree" version = "4.0.0" +source = "git+https://github.com/dashpay/grovedb?rev=679ea5a230a9d2e584ac2949ecac179a179a0444#679ea5a230a9d2e584ac2949ecac179a179a0444" dependencies = [ "bincode 2.0.1", "blake3", + "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=679ea5a230a9d2e584ac2949ecac179a179a0444)", + "grovedb-query", + "grovedb-storage", "thiserror 2.0.18", ] [[package]] name = "grovedb-element" version = "4.0.0" +source = "git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897#33dfd48a1718160cb333fa95424be491785f1897" dependencies = [ "bincode 2.0.1", "bincode_derive", - "grovedb-path 4.0.0", - "grovedb-version 4.0.0", + "grovedb-path 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", + "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", "hex", "integer-encoding", "thiserror 2.0.18", @@ -3690,12 +3693,12 @@ dependencies = [ [[package]] name = "grovedb-element" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897#33dfd48a1718160cb333fa95424be491785f1897" +source = "git+https://github.com/dashpay/grovedb?rev=679ea5a230a9d2e584ac2949ecac179a179a0444#679ea5a230a9d2e584ac2949ecac179a179a0444" dependencies = [ "bincode 2.0.1", "bincode_derive", - "grovedb-path 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", - "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", + "grovedb-path 4.0.0 (git+https://github.com/dashpay/grovedb?rev=679ea5a230a9d2e584ac2949ecac179a179a0444)", + "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=679ea5a230a9d2e584ac2949ecac179a179a0444)", "hex", "integer-encoding", "thiserror 2.0.18", @@ -3704,8 +3707,9 @@ dependencies = [ [[package]] name = "grovedb-epoch-based-storage-flags" version = "4.0.0" +source = "git+https://github.com/dashpay/grovedb?rev=679ea5a230a9d2e584ac2949ecac179a179a0444#679ea5a230a9d2e584ac2949ecac179a179a0444" dependencies = [ - "grovedb-costs 4.0.0", + "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=679ea5a230a9d2e584ac2949ecac179a179a0444)", "hex", "integer-encoding", "intmap", @@ -3715,17 +3719,18 @@ dependencies = [ [[package]] name = "grovedb-merk" version = "4.0.0" +source = "git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897#33dfd48a1718160cb333fa95424be491785f1897" dependencies = [ "bincode 2.0.1", "bincode_derive", "blake3", "byteorder", "ed", - "grovedb-costs 4.0.0", - "grovedb-element 4.0.0", - "grovedb-path 4.0.0", - "grovedb-version 4.0.0", - "grovedb-visualize 4.0.0", + "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", + "grovedb-element 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", + "grovedb-path 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", + "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", + "grovedb-visualize 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", "hex", "indexmap 2.13.0", "integer-encoding", @@ -3735,18 +3740,19 @@ dependencies = [ [[package]] name = "grovedb-merk" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897#33dfd48a1718160cb333fa95424be491785f1897" +source = "git+https://github.com/dashpay/grovedb?rev=679ea5a230a9d2e584ac2949ecac179a179a0444#679ea5a230a9d2e584ac2949ecac179a179a0444" dependencies = [ "bincode 2.0.1", "bincode_derive", "blake3", "byteorder", "ed", - "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", - "grovedb-element 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", - "grovedb-path 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", - "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", - "grovedb-visualize 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", + "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=679ea5a230a9d2e584ac2949ecac179a179a0444)", + "grovedb-element 4.0.0 (git+https://github.com/dashpay/grovedb?rev=679ea5a230a9d2e584ac2949ecac179a179a0444)", + "grovedb-path 4.0.0 (git+https://github.com/dashpay/grovedb?rev=679ea5a230a9d2e584ac2949ecac179a179a0444)", + "grovedb-query", + "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=679ea5a230a9d2e584ac2949ecac179a179a0444)", + "grovedb-visualize 4.0.0 (git+https://github.com/dashpay/grovedb?rev=679ea5a230a9d2e584ac2949ecac179a179a0444)", "hex", "indexmap 2.13.0", "integer-encoding", @@ -3754,18 +3760,20 @@ dependencies = [ ] [[package]] -name = "grovedb-mmr" +name = "grovedb-merkle-mountain-range" version = "4.0.0" +source = "git+https://github.com/dashpay/grovedb?rev=679ea5a230a9d2e584ac2949ecac179a179a0444#679ea5a230a9d2e584ac2949ecac179a179a0444" dependencies = [ "bincode 2.0.1", "blake3", - "ckb-merkle-mountain-range", - "thiserror 2.0.18", + "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=679ea5a230a9d2e584ac2949ecac179a179a0444)", + "grovedb-storage", ] [[package]] name = "grovedb-path" version = "4.0.0" +source = "git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897#33dfd48a1718160cb333fa95424be491785f1897" dependencies = [ "hex", ] @@ -3773,14 +3781,42 @@ dependencies = [ [[package]] name = "grovedb-path" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897#33dfd48a1718160cb333fa95424be491785f1897" +source = "git+https://github.com/dashpay/grovedb?rev=679ea5a230a9d2e584ac2949ecac179a179a0444#679ea5a230a9d2e584ac2949ecac179a179a0444" +dependencies = [ + "hex", +] + +[[package]] +name = "grovedb-query" +version = "4.0.0" +source = "git+https://github.com/dashpay/grovedb?rev=679ea5a230a9d2e584ac2949ecac179a179a0444#679ea5a230a9d2e584ac2949ecac179a179a0444" dependencies = [ + "bincode 2.0.1", + "byteorder", + "ed", "hex", + "indexmap 2.13.0", + "integer-encoding", + "thiserror 2.0.18", +] + +[[package]] +name = "grovedb-storage" +version = "4.0.0" +source = "git+https://github.com/dashpay/grovedb?rev=679ea5a230a9d2e584ac2949ecac179a179a0444#679ea5a230a9d2e584ac2949ecac179a179a0444" +dependencies = [ + "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=679ea5a230a9d2e584ac2949ecac179a179a0444)", + "grovedb-path 4.0.0 (git+https://github.com/dashpay/grovedb?rev=679ea5a230a9d2e584ac2949ecac179a179a0444)", + "grovedb-visualize 4.0.0 (git+https://github.com/dashpay/grovedb?rev=679ea5a230a9d2e584ac2949ecac179a179a0444)", + "hex", + "strum 0.27.2", + "thiserror 2.0.18", ] [[package]] name = "grovedb-version" version = "4.0.0" +source = "git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897#33dfd48a1718160cb333fa95424be491785f1897" dependencies = [ "thiserror 2.0.18", "versioned-feature-core 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)", @@ -3789,7 +3825,7 @@ dependencies = [ [[package]] name = "grovedb-version" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897#33dfd48a1718160cb333fa95424be491785f1897" +source = "git+https://github.com/dashpay/grovedb?rev=679ea5a230a9d2e584ac2949ecac179a179a0444#679ea5a230a9d2e584ac2949ecac179a179a0444" dependencies = [ "thiserror 2.0.18", "versioned-feature-core 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)", @@ -3798,6 +3834,7 @@ dependencies = [ [[package]] name = "grovedb-visualize" version = "4.0.0" +source = "git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897#33dfd48a1718160cb333fa95424be491785f1897" dependencies = [ "hex", "itertools 0.14.0", @@ -3806,7 +3843,7 @@ dependencies = [ [[package]] name = "grovedb-visualize" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897#33dfd48a1718160cb333fa95424be491785f1897" +source = "git+https://github.com/dashpay/grovedb?rev=679ea5a230a9d2e584ac2949ecac179a179a0444#679ea5a230a9d2e584ac2949ecac179a179a0444" dependencies = [ "hex", "itertools 0.14.0", @@ -4566,9 +4603,9 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jiff" -version = "0.2.20" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c867c356cc096b33f4981825ab281ecba3db0acefe60329f044c1789d94c6543" +checksum = "b3e3d65f018c6ae946ab16e80944b97096ed73c35b221d1c478a6c81d8f57940" dependencies = [ "jiff-static", "log", @@ -4579,13 +4616,13 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.20" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7946b4325269738f270bb55b3c19ab5c5040525f83fd625259422a9d25d9be5" +checksum = "a17c2b211d863c7fde02cbea8a3c1a439b98e109286554f2860bdded7ff83818" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -4622,9 +4659,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.85" +version = "0.3.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +checksum = "f4eacb0641a310445a4c513f2a5e23e19952e269c6a38887254d5f837a305506" dependencies = [ "once_cell", "wasm-bindgen", @@ -4719,7 +4756,7 @@ dependencies = [ [[package]] name = "keyword-search-contract" -version = "3.0.1" +version = "3.1.0-dev.1" dependencies = [ "platform-value", "platform-version", @@ -4841,6 +4878,7 @@ version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a" dependencies = [ + "cc", "pkg-config", "vcpkg", ] @@ -4913,7 +4951,7 @@ dependencies = [ [[package]] name = "masternode-reward-shares-contract" -version = "3.0.1" +version = "3.1.0-dev.1" dependencies = [ "platform-value", "platform-version", @@ -5147,9 +5185,9 @@ dependencies = [ [[package]] name = "native-tls" -version = "0.2.16" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d5d26952a508f321b4d3d2e80e78fc2603eaefcdf0c30783867f19586518bdc" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" dependencies = [ "libc", "log", @@ -5295,7 +5333,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -5390,7 +5428,7 @@ dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -5719,7 +5757,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -5759,6 +5797,7 @@ dependencies = [ [[package]] name = "orchard" version = "0.12.0" +source = "git+https://github.com/dashpay/orchard.git?rev=41c8f7169f2683c99cf0e0c63e8d25ec12c47a79#41c8f7169f2683c99cf0e0c63e8d25ec12c47a79" dependencies = [ "aes", "bitvec", @@ -5960,7 +5999,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", "unicase", ] @@ -5997,7 +6036,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -6041,7 +6080,7 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "platform-serialization" -version = "3.0.1" +version = "3.1.0-dev.1" dependencies = [ "bincode 2.0.1", "platform-version", @@ -6049,17 +6088,17 @@ dependencies = [ [[package]] name = "platform-serialization-derive" -version = "3.0.1" +version = "3.1.0-dev.1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", "virtue 0.0.17", ] [[package]] name = "platform-value" -version = "3.0.1" +version = "3.1.0-dev.1" dependencies = [ "base64 0.22.1", "bincode 2.0.1", @@ -6078,21 +6117,21 @@ dependencies = [ [[package]] name = "platform-version" -version = "3.0.1" +version = "3.1.0-dev.1" dependencies = [ "bincode 2.0.1", - "grovedb-version 4.0.0", + "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=679ea5a230a9d2e584ac2949ecac179a179a0444)", "thiserror 2.0.18", "versioned-feature-core 1.0.0 (git+https://github.com/dashpay/versioned-feature-core)", ] [[package]] name = "platform-versioning" -version = "3.0.1" +version = "3.1.0-dev.1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -6216,7 +6255,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -6257,7 +6296,7 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] @@ -6302,7 +6341,7 @@ dependencies = [ "pulldown-cmark", "pulldown-cmark-to-cmark", "regex", - "syn 2.0.116", + "syn 2.0.117", "tempfile", ] @@ -6316,7 +6355,7 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -6330,9 +6369,9 @@ dependencies = [ [[package]] name = "pulldown-cmark" -version = "0.13.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" +checksum = "83c41efbf8f90ac44de7f3a868f0867851d261b56291732d0cbf7cceaaeb55a6" dependencies = [ "bitflags 2.11.0", "memchr", @@ -6847,7 +6886,7 @@ dependencies = [ [[package]] name = "rs-dapi-client" -version = "3.0.1" +version = "3.1.0-dev.1" dependencies = [ "backon", "chrono", @@ -6926,7 +6965,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.116", + "syn 2.0.117", "walkdir", ] @@ -7174,9 +7213,9 @@ dependencies = [ [[package]] name = "security-framework" -version = "3.6.0" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.11.0", "core-foundation 0.10.1", @@ -7187,9 +7226,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.16.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "321c8673b092a9a42605034a9879d73cb79101ed5fd117bc9a597b89b4e9e61a" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -7247,7 +7286,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -7272,7 +7311,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -7321,7 +7360,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -7379,11 +7418,11 @@ dependencies = [ [[package]] name = "shardtree" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "637e95dcd06bc1bb3f86ed9db1e1832a70125f32daae071ef37dcb7701b7d4fe" +checksum = "359e552886ae54d1642091645980d83f7db465fd9b5b0248e3680713c1773388" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "either", "incrementalmerkletree", "tracing", @@ -7655,7 +7694,16 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros", + "strum_macros 0.26.4", +] + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", ] [[package]] @@ -7668,7 +7716,19 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.116", + "syn 2.0.117", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -7709,9 +7769,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.116" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -7735,7 +7795,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -7885,7 +7945,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -7896,7 +7956,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -8015,7 +8075,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "token-history-contract" -version = "3.0.1" +version = "3.1.0-dev.1" dependencies = [ "platform-value", "platform-version", @@ -8048,7 +8108,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -8183,9 +8243,9 @@ dependencies = [ [[package]] name = "tonic" -version = "0.14.4" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f32a6f80051a4111560201420c7885d0082ba9efe2ab61875c587bb6b18b9a0" +checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec" dependencies = [ "async-trait", "base64 0.22.1", @@ -8214,21 +8274,21 @@ dependencies = [ [[package]] name = "tonic-build" -version = "0.14.4" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6d8958ed3be404120ca43ffa0fb1e1fc7be214e96c8d33bd43a131b6eebc9e" +checksum = "1882ac3bf5ef12877d7ed57aad87e75154c11931c2ba7e6cde5e22d63522c734" dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] name = "tonic-prost" -version = "0.14.4" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f86539c0089bfd09b1f8c0ab0239d80392af74c21bc9e0f15e1b4aca4c1647f" +checksum = "a55376a0bbaa4975a3f10d009ad763d8f4108f067c7c2e74f3001fb49778d309" dependencies = [ "bytes", "prost", @@ -8237,16 +8297,16 @@ dependencies = [ [[package]] name = "tonic-prost-build" -version = "0.14.4" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65873ace111e90344b8973e94a1fc817c924473affff24629281f90daed1cd2e" +checksum = "f3144df636917574672e93d0f56d7edec49f90305749c668df5101751bb8f95a" dependencies = [ "prettyplease", "proc-macro2", "prost-build", "prost-types", "quote", - "syn 2.0.116", + "syn 2.0.117", "tempfile", "tonic-build", ] @@ -8357,7 +8417,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -8752,7 +8812,7 @@ checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] @@ -8785,7 +8845,7 @@ dependencies = [ [[package]] name = "wallet-utils-contract" -version = "3.0.1" +version = "3.1.0-dev.1" dependencies = [ "platform-value", "platform-version", @@ -8828,9 +8888,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.108" +version = "0.2.112" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +checksum = "05d7d0fce354c88b7982aec4400b3e7fcf723c32737cef571bd165f7613557ee" dependencies = [ "cfg-if", "once_cell", @@ -8841,9 +8901,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.58" +version = "0.4.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +checksum = "ee85afca410ac4abba5b584b12e77ea225db6ee5471d0aebaae0861166f9378a" dependencies = [ "cfg-if", "futures-util", @@ -8855,9 +8915,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.108" +version = "0.2.112" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +checksum = "55839b71ba921e4f75b674cb16f843f4b1f3b26ddfcb3454de1cf65cc021ec0f" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -8865,22 +8925,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.108" +version = "0.2.112" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +checksum = "caf2e969c2d60ff52e7e98b7392ff1588bffdd1ccd4769eba27222fd3d621571" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.108" +version = "0.2.112" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +checksum = "0861f0dcdf46ea819407495634953cdcc8a8c7215ab799a7a7ce366be71c7b30" dependencies = [ "unicode-ident", ] @@ -9082,9 +9142,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.85" +version = "0.3.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +checksum = "10053fbf9a374174094915bbce141e87a6bf32ecd9a002980db4b638405e8962" dependencies = [ "js-sys", "wasm-bindgen", @@ -9463,7 +9523,7 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -9474,7 +9534,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -9485,7 +9545,7 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -9496,7 +9556,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -10050,7 +10110,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d31a19dae58475d019850e25b0170e94b16d382fbf6afee9c0e80fdc935e73e" dependencies = [ "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -10131,7 +10191,7 @@ dependencies = [ "heck", "indexmap 2.13.0", "prettyplease", - "syn 2.0.116", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -10147,7 +10207,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -10191,7 +10251,7 @@ dependencies = [ [[package]] name = "withdrawals-contract" -version = "3.0.1" +version = "3.1.0-dev.1" dependencies = [ "num_enum 0.5.11", "platform-value", @@ -10305,15 +10365,15 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", "synstructure", ] [[package]] name = "zbus" -version = "5.13.2" +version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfeff997a0aaa3eb20c4652baf788d2dfa6d2839a0ead0b3ff69ce2f9c4bdd1" +checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" dependencies = [ "async-broadcast", "async-executor", @@ -10362,7 +10422,7 @@ checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", "zbus-lockstep", "zbus_xml", "zvariant", @@ -10370,14 +10430,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.13.2" +version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bbd5a90dbe8feee5b13def448427ae314ccd26a49cac47905cafefb9ff846f1" +checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", "zbus_names", "zvariant", "zvariant_utils", @@ -10409,6 +10469,7 @@ dependencies = [ [[package]] name = "zcash_note_encryption" version = "0.4.1" +source = "git+https://github.com/dashpay/zcash_note_encryption?rev=9f7e93d#9f7e93d42cef839d02b9d75918117941d453f8cb" dependencies = [ "chacha20", "chacha20poly1305", @@ -10443,7 +10504,7 @@ checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -10463,7 +10524,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", "synstructure", ] @@ -10485,7 +10546,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -10555,7 +10616,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -10587,9 +10648,9 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.0" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7948af682ccbc3342b6e9420e8c51c1fe5d7bf7756002b4a3c6cabfe96a7e3c" +checksum = "c745c48e1007337ed136dc99df34128b9faa6ed542d80a1c673cf55a6d7236c8" [[package]] name = "zmij" @@ -10663,9 +10724,9 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.9.2" +version = "5.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68b64ef4f40c7951337ddc7023dd03528a57a3ce3408ee9da5e948bd29b232c4" +checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" dependencies = [ "endi", "enumflags2", @@ -10678,14 +10739,14 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.9.2" +version = "5.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "484d5d975eb7afb52cc6b929c13d3719a20ad650fea4120e6310de3fc55e415c" +checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", "zvariant_utils", ] @@ -10698,7 +10759,7 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.116", + "syn 2.0.117", "winnow 0.7.14", ] diff --git a/Cargo.toml b/Cargo.toml index d4c7ea41d..1e197414e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -108,7 +108,28 @@ level = "warn" check-cfg = ["cfg(tokio_unstable)"] [patch."https://github.com/dashpay/platform"] +dapi-grpc = { path = "../platform/packages/dapi-grpc" } +dash-context-provider = { path = "../platform/packages/rs-context-provider" } +dash-platform-macros = { path = "../platform/packages/rs-dash-platform-macros" } dash-sdk = { path = "../platform/packages/rs-sdk" } +dashpay-contract = { path = "../platform/packages/dashpay-contract" } +data-contracts = { path = "../platform/packages/data-contracts" } +dpns-contract = { path = "../platform/packages/dpns-contract" } +dpp = { path = "../platform/packages/rs-dpp" } +drive = { path = "../platform/packages/rs-drive" } +drive-proof-verifier = { path = "../platform/packages/rs-drive-proof-verifier" } +feature-flags-contract = { path = "../platform/packages/feature-flags-contract" } +keyword-search-contract = { path = "../platform/packages/keyword-search-contract" } +masternode-reward-shares-contract = { path = "../platform/packages/masternode-reward-shares-contract" } +platform-serialization = { path = "../platform/packages/rs-platform-serialization" } +platform-serialization-derive = { path = "../platform/packages/rs-platform-serialization-derive" } +platform-value = { path = "../platform/packages/rs-platform-value" } +platform-version = { path = "../platform/packages/rs-platform-version" } +platform-versioning = { path = "../platform/packages/rs-platform-versioning" } +rs-dapi-client = { path = "../platform/packages/rs-dapi-client" } +token-history-contract = { path = "../platform/packages/token-history-contract" } +wallet-utils-contract = { path = "../platform/packages/wallet-utils-contract" } +withdrawals-contract = { path = "../platform/packages/withdrawals-contract" } [lints.clippy] uninlined_format_args = "allow" diff --git a/scripts/configure-local.sh b/scripts/configure-local.sh new file mode 100755 index 000000000..39923269f --- /dev/null +++ b/scripts/configure-local.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Configure Dash Evo Tool for a local dashmate network. +# Reads ports and credentials from dashmate config, then updates the .env file. +# +# Usage: ./configure-local.sh [config_name] +# config_name: dashmate config name (default: local_seed) + +set -euo pipefail + +CONFIG="${1:-local_seed}" + +# Detect .env path +case "$(uname)" in + Darwin) ENV_DIR="$HOME/Library/Application Support/Dash-Evo-Tool" ;; + *) ENV_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/dash-evo-tool" ;; +esac +ENV_FILE="$ENV_DIR/.env" + +echo "Reading dashmate config (--config=$CONFIG)..." + +RPC_PASSWORD=$(dashmate config get core.rpc.users.dashmate.password --config="$CONFIG" 2>/dev/null) \ + || { echo "Error: Could not read RPC password. Is dashmate installed and '$CONFIG' configured?"; exit 1; } +RPC_PORT=$(dashmate config get core.rpc.port --config="$CONFIG") +ZMQ_PORT=$(dashmate config get core.zmq.port --config="$CONFIG") +DAPI_PORT=$(dashmate config get platform.gateway.listeners.dapiAndDrive.port --config="$CONFIG") + +HOST="127.0.0.1" + +echo "" +echo " RPC Password : $RPC_PASSWORD" +echo " RPC Port : $RPC_PORT" +echo " DAPI Port : $DAPI_PORT" +echo " ZMQ Port : $ZMQ_PORT" +echo " Host : $HOST" +echo "" + +LOCAL_BLOCK="LOCAL_dapi_addresses=http://${HOST}:${DAPI_PORT} +LOCAL_core_host=${HOST} +LOCAL_core_rpc_port=${RPC_PORT} +LOCAL_core_rpc_user=dashmate +LOCAL_core_rpc_password=${RPC_PASSWORD} +LOCAL_insight_api_url=http://localhost:3001/insight-api +LOCAL_core_zmq_endpoint=tcp://${HOST}:${ZMQ_PORT} +LOCAL_show_in_ui=true" + +# Ensure directory exists +mkdir -p "$ENV_DIR" + +if [ ! -f "$ENV_FILE" ]; then + echo "$LOCAL_BLOCK" > "$ENV_FILE" + echo "Created $ENV_FILE with LOCAL config." + exit 0 +fi + +# Remove existing LOCAL_ lines +CLEANED=$(grep -v '^LOCAL_' "$ENV_FILE" || true) + +# Remove trailing blank lines, then append LOCAL block +CLEANED=$(echo "$CLEANED" | sed -e :a -e '/^\n*$/{$d;N;ba}') + +printf '%s\n\n%s\n' "$CLEANED" "$LOCAL_BLOCK" > "$ENV_FILE" + +echo "Updated $ENV_FILE with LOCAL config." diff --git a/src/backend_task/core/mod.rs b/src/backend_task/core/mod.rs index 797af5348..581bfba75 100644 --- a/src/backend_task/core/mod.rs +++ b/src/backend_task/core/mod.rs @@ -46,18 +46,14 @@ fn networks_address_compatible(a: &Network, b: &Network) -> bool { ) } -use crate::backend_task::wallet::PlatformSyncMode; - #[derive(Debug, Clone)] pub enum CoreTask { #[allow(dead_code)] // May be used for getting single chain lock GetBestChainLock, GetBestChainLocks, - /// Refresh wallet info from Core. The optional PlatformSyncMode controls whether - /// and how to sync Platform address balances: - /// - None: Skip Platform sync entirely (Core only) - /// - Some(mode): Sync Platform with the specified mode - RefreshWalletInfo(Arc>, Option), + /// Refresh wallet info from Core. The bool controls whether to also sync + /// Platform address balances (true) or skip Platform sync (false, Core only). + RefreshWalletInfo(Arc>, bool), RefreshSingleKeyWalletInfo(Arc>), StartDashQT(Network, PathBuf, bool), CreateRegistrationAssetLock(Arc>, Credits, u32), // wallet, amount in credits, identity index @@ -71,6 +67,11 @@ pub enum CoreTask { request: WalletPaymentRequest, }, RecoverAssetLocks(Arc>), + MineBlocks { + block_count: u64, + address: Address, + wallet: Arc>, + }, } impl PartialEq for CoreTask { fn eq(&self, other: &Self) -> bool { @@ -110,6 +111,7 @@ impl PartialEq for CoreTask { CoreTask::RecoverAssetLocks(_), CoreTask::RecoverAssetLocks(_), ) + | (CoreTask::MineBlocks { .. }, CoreTask::MineBlocks { .. }) ) } } @@ -203,7 +205,7 @@ impl AppContext { local_chainlock, ))) } - CoreTask::RefreshWalletInfo(wallet, platform_sync_mode) => { + CoreTask::RefreshWalletInfo(wallet, sync_platform) => { // Get wallet seed hash for Platform balance refresh let seed_hash = { let wallet_guard = wallet.read().map_err(|e| e.to_string())?; @@ -223,12 +225,9 @@ impl AppContext { .map_err(|e| format!("Error refreshing wallet: {}", e))?; } - // Also refresh Platform address balances if a sync mode is specified - let warning = if let Some(sync_mode) = platform_sync_mode { - match self - .fetch_platform_address_balances(seed_hash, sync_mode) - .await - { + // Also refresh Platform address balances if requested + let warning = if sync_platform { + match self.fetch_platform_address_balances(seed_hash).await { Ok(_) => None, Err(e) => { tracing::warn!("Failed to fetch Platform address balances: {}", e); @@ -275,6 +274,29 @@ impl AppContext { .await .map_err(|e| format!("Task join error: {}", e))? } + CoreTask::MineBlocks { + block_count, + address, + wallet, + } => { + let mined = self + .core_client + .read() + .expect("Core client lock was poisoned") + .generate_to_address(block_count, &address) + .map_err(|e| e.to_string())?; + + let mined_count = mined.len(); + + // Refresh wallet balances via RPC so the UI reflects the new coins + let ctx = self.clone(); + tokio::task::spawn_blocking(move || ctx.refresh_wallet_info(wallet)) + .await + .map_err(|e| format!("Task join error: {}", e))? + .map_err(|e| format!("Error refreshing wallet after mining: {}", e))?; + + Ok(BackendTaskSuccessResult::MineBlocksSuccess(mined_count)) + } } } diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index 1a31e685c..23834e577 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -261,6 +261,7 @@ pub enum BackendTaskSuccessResult { recovered_count: usize, total_amount: u64, }, + MineBlocksSuccess(usize), // DPNS operation results (replacing string messages) ScheduledVotes, @@ -296,6 +297,14 @@ pub enum BackendTaskSuccessResult { seed_hash: WalletSeedHash, spent_count: u32, }, + ShieldedFromAssetLock { + seed_hash: WalletSeedHash, + amount: u64, + }, + ShieldedWithdrawalComplete { + seed_hash: WalletSeedHash, + amount: u64, + }, ProvingKeyReady, } @@ -395,12 +404,8 @@ impl AppContext { WalletTask::GenerateReceiveAddress { seed_hash } => { self.generate_receive_address(seed_hash).await } - WalletTask::FetchPlatformAddressBalances { - seed_hash, - sync_mode, - } => { - self.fetch_platform_address_balances(seed_hash, sync_mode) - .await + WalletTask::FetchPlatformAddressBalances { seed_hash } => { + self.fetch_platform_address_balances(seed_hash).await } WalletTask::TransferPlatformCredits { seed_hash, diff --git a/src/backend_task/platform_info.rs b/src/backend_task/platform_info.rs index 6cbf246f0..674a6575b 100644 --- a/src/backend_task/platform_info.rs +++ b/src/backend_task/platform_info.rs @@ -41,6 +41,7 @@ pub enum PlatformInfoTaskRequestType { CurrentWithdrawalsInQueue, RecentlyCompletedWithdrawals, BasicPlatformInfo, + ShieldedPoolState, FetchAddressBalance(String), } @@ -624,6 +625,26 @@ impl AppContext { )), } } + PlatformInfoTaskRequestType::ShieldedPoolState => { + use dash_sdk::query_types::ShieldedPoolState; + + match ShieldedPoolState::fetch_current(&sdk).await { + Ok(pool_state) => { + let total_credits = pool_state.0; + let dash_amount = total_credits as f64 / (dash_to_credits!(1) as f64); + let formatted = format!( + "Shielded Pool State:\n\n\ + • Total Balance: {} credits\n\ + • Dash Equivalent: {:.8} DASH", + total_credits, dash_amount, + ); + Ok(BackendTaskSuccessResult::PlatformInfo( + PlatformInfoTaskResult::TextResult(formatted), + )) + } + Err(e) => Err(format!("Failed to fetch shielded pool state: {}", e)), + } + } PlatformInfoTaskRequestType::FetchAddressBalance(address_string) => { // Parse the address string into a PlatformAddress let platform_address: PlatformAddress = address_string diff --git a/src/backend_task/shielded/bundle.rs b/src/backend_task/shielded/bundle.rs index 12b9065a0..4390c238b 100644 --- a/src/backend_task/shielded/bundle.rs +++ b/src/backend_task/shielded/bundle.rs @@ -2,15 +2,113 @@ use crate::context::AppContext; use crate::context::shielded::get_proving_key; use crate::model::wallet::WalletSeedHash; use crate::model::wallet::shielded::ShieldedWalletState; -use dash_sdk::dpp::address_funds::{AddressFundsFeeStrategy, OrchardAddress, PlatformAddress}; +use dash_sdk::dpp::address_funds::{ + AddressFundsFeeStrategy, AddressFundsFeeStrategyStep, OrchardAddress, PlatformAddress, +}; +use dash_sdk::dpp::dashcore::Address; +use dash_sdk::dpp::identity::core_script::CoreScript; use dash_sdk::dpp::shielded::builder::{ SpendableNote, build_shield_transition, build_shielded_transfer_transition, - build_unshield_transition, + build_shielded_withdrawal_transition, build_unshield_transition, }; -use dash_sdk::grovedb_commitment_tree::PaymentAddress; +use dash_sdk::dpp::withdrawal::Pooling; +use dash_sdk::grovedb_commitment_tree::{Nullifier, PaymentAddress}; use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; use std::collections::BTreeMap; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; + +/// Progress stage for a shield credits operation (used by batch UI). +#[derive(Clone, Debug)] +pub enum ShieldStage { + Queued, + BuildingProof { + nonce: u32, + }, + WaitingToBroadcast, + Broadcasting, + Complete, + Failed { + error: String, + st_json: Option, + }, +} + +impl ShieldStage { + pub fn is_terminal(&self) -> bool { + matches!(self, ShieldStage::Complete | ShieldStage::Failed { .. }) + } + + pub fn progress_fraction(&self) -> f32 { + match self { + ShieldStage::Queued => 0.0, + ShieldStage::BuildingProof { .. } => 0.4, + ShieldStage::WaitingToBroadcast => 0.6, + ShieldStage::Broadcasting => 0.8, + ShieldStage::Complete => 1.0, + ShieldStage::Failed { .. } => 1.0, + } + } + + pub fn label(&self) -> String { + match self { + ShieldStage::Queued => "Queued".to_string(), + ShieldStage::BuildingProof { nonce } => { + format!("Building proof... (nonce: {})", nonce) + } + ShieldStage::WaitingToBroadcast => "Waiting to broadcast...".to_string(), + ShieldStage::Broadcasting => { + "Broadcasting & waiting for nonce confirmation...".to_string() + } + ShieldStage::Complete => "Complete".to_string(), + ShieldStage::Failed { error, .. } => format!("Failed: {}", error), + } + } +} + +/// Build a Shield transition without broadcasting (for batch parallel mode). +/// +/// Returns the built `StateTransition` so the caller can broadcast in nonce order. +pub fn build_shield_credit( + app_context: &Arc, + seed_hash: &WalletSeedHash, + recipient_payment_address: &PaymentAddress, + amount: u64, + from_address: PlatformAddress, + nonce: u32, +) -> Result { + let sdk = { + let guard = app_context.sdk.read().unwrap(); + guard.clone() + }; + + let proving_key = get_proving_key(); + let recipient_addr = payment_address_to_orchard(recipient_payment_address); + + let wallet_arc = { + let wallets = app_context.wallets.read().unwrap(); + wallets.get(seed_hash).cloned().ok_or("Wallet not found")? + }; + + let mut inputs = BTreeMap::new(); + inputs.insert(from_address, (nonce, amount)); + + let fee_strategy: AddressFundsFeeStrategy = + vec![AddressFundsFeeStrategyStep::DeductFromInput(0)]; + + let wallet = wallet_arc.read().unwrap(); + build_shield_transition( + &recipient_addr, + amount, + inputs, + fee_strategy, + &*wallet, + 0, + proving_key, + [0u8; 36], + sdk.version(), + ) + .map_err(|e| format!("Failed to build shield transition: {e}")) +} /// Build and broadcast a Shield transition (transparent -> shielded pool). /// @@ -19,9 +117,11 @@ use std::sync::Arc; pub async fn shield_credits( app_context: &Arc, seed_hash: &WalletSeedHash, - shielded_state: &ShieldedWalletState, + recipient_payment_address: &PaymentAddress, amount: u64, from_address: PlatformAddress, + nonce_override: Option, + stage: Option>>, ) -> Result<(), String> { let sdk = { let guard = app_context.sdk.read().unwrap(); @@ -31,7 +131,7 @@ pub async fn shield_credits( let proving_key = get_proving_key(); // Build recipient Orchard address from our default payment address - let recipient_addr = payment_address_to_orchard(&shielded_state.keys.default_address); + let recipient_addr = payment_address_to_orchard(recipient_payment_address); // Get the wallet for signing and nonce lookup let wallet_arc = { @@ -39,8 +139,11 @@ pub async fn shield_credits( wallets.get(seed_hash).cloned().ok_or("Wallet not found")? }; - // Get the nonce for the input address from the wallet's platform address info - let (nonce, _balance) = { + // Get the nonce for the input address from the wallet's platform address info, + // unless a nonce override was provided (batch parallel mode). + let nonce: u32 = if let Some(n) = nonce_override { + n + } else { let wallet = wallet_arc.read().unwrap(); wallet .platform_address_info @@ -48,7 +151,7 @@ pub async fn shield_credits( .find_map(|(addr, info)| { let platform_addr = PlatformAddress::try_from(addr.clone()).ok()?; if platform_addr == from_address { - Some((info.nonce + 1, info.balance)) + Some(info.nonce + 1) } else { None } @@ -59,7 +162,12 @@ pub async fn shield_credits( let mut inputs = BTreeMap::new(); inputs.insert(from_address, (nonce, amount)); - let fee_strategy: AddressFundsFeeStrategy = vec![]; + let fee_strategy: AddressFundsFeeStrategy = + vec![AddressFundsFeeStrategyStep::DeductFromInput(0)]; + + if let Some(s) = &stage { + *s.lock().unwrap() = ShieldStage::BuildingProof { nonce }; + } // Use the DPP builder which handles bundle construction internally let state_transition = { @@ -78,6 +186,10 @@ pub async fn shield_credits( .map_err(|e| format!("Failed to build shield transition: {e}"))? }; + if let Some(s) = &stage { + *s.lock().unwrap() = ShieldStage::Broadcasting; + } + state_transition .broadcast(&sdk, None) .await @@ -87,13 +199,15 @@ pub async fn shield_credits( } /// Build and broadcast a ShieldedTransfer transition (pool -> pool). +/// +/// Returns the nullifiers of the notes that were spent. pub async fn shielded_transfer( app_context: &Arc, _seed_hash: &WalletSeedHash, shielded_state: &ShieldedWalletState, amount: u64, recipient_address_bytes: &[u8], -) -> Result<(), String> { +) -> Result, String> { let sdk = { let guard = app_context.sdk.read().unwrap(); guard.clone() @@ -110,26 +224,31 @@ pub async fn shielded_transfer( // Select notes to spend let (spendable_notes, _total_value) = select_notes_for_amount(shielded_state, amount)?; - // Get Merkle witness for each note - let spends = spendable_notes - .iter() - .map(|note| { - let merkle_path = shielded_state - .commitment_tree - .witness(note.position, 0) - .map_err(|e| format!("Failed to get Merkle witness: {e}"))? - .ok_or("No Merkle path available for note")?; - Ok(SpendableNote { - note: note.note, - merkle_path, + // Collect nullifiers of the notes we're about to spend + let spent_nullifiers: Vec = spendable_notes.iter().map(|n| n.nullifier).collect(); + + // Get Merkle witness and anchor (lock scoped to avoid holding across await) + let (spends, anchor) = { + let tree = shielded_state.commitment_tree.lock().unwrap(); + let spends = spendable_notes + .iter() + .map(|note| { + let merkle_path = tree + .witness(note.position, 0) + .map_err(|e| format!("Failed to get Merkle witness: {e}"))? + .ok_or("No Merkle path available for note")?; + Ok(SpendableNote { + note: note.note, + merkle_path, + }) }) - }) - .collect::, String>>()?; + .collect::, String>>()?; - let anchor = shielded_state - .commitment_tree - .anchor() - .map_err(|e| format!("Failed to get tree anchor: {e}"))?; + let anchor = tree + .anchor() + .map_err(|e| format!("Failed to get tree anchor: {e}"))?; + (spends, anchor) + }; let change_addr = payment_address_to_orchard(&shielded_state.keys.default_address); @@ -152,17 +271,19 @@ pub async fn shielded_transfer( .await .map_err(|e| format!("Failed to broadcast shielded transfer: {e}"))?; - Ok(()) + Ok(spent_nullifiers) } /// Build and broadcast an Unshield transition (shielded pool -> platform address). +/// +/// Returns the nullifiers of the notes that were spent. pub async fn unshield_credits( app_context: &Arc, _seed_hash: &WalletSeedHash, shielded_state: &ShieldedWalletState, amount: u64, to_platform_address: PlatformAddress, -) -> Result<(), String> { +) -> Result, String> { let sdk = { let guard = app_context.sdk.read().unwrap(); guard.clone() @@ -173,26 +294,31 @@ pub async fn unshield_credits( // Select notes to spend let (spendable_notes, _total_value) = select_notes_for_amount(shielded_state, amount)?; - // Get Merkle witness for each note - let spends = spendable_notes - .iter() - .map(|note| { - let merkle_path = shielded_state - .commitment_tree - .witness(note.position, 0) - .map_err(|e| format!("Failed to get Merkle witness: {e}"))? - .ok_or("No Merkle path available for note")?; - Ok(SpendableNote { - note: note.note, - merkle_path, + // Collect nullifiers of the notes we're about to spend + let spent_nullifiers: Vec = spendable_notes.iter().map(|n| n.nullifier).collect(); + + // Get Merkle witness and anchor (lock scoped to avoid holding across await) + let (spends, anchor) = { + let tree = shielded_state.commitment_tree.lock().unwrap(); + let spends = spendable_notes + .iter() + .map(|note| { + let merkle_path = tree + .witness(note.position, 0) + .map_err(|e| format!("Failed to get Merkle witness: {e}"))? + .ok_or("No Merkle path available for note")?; + Ok(SpendableNote { + note: note.note, + merkle_path, + }) }) - }) - .collect::, String>>()?; + .collect::, String>>()?; - let anchor = shielded_state - .commitment_tree - .anchor() - .map_err(|e| format!("Failed to get tree anchor: {e}"))?; + let anchor = tree + .anchor() + .map_err(|e| format!("Failed to get tree anchor: {e}"))?; + (spends, anchor) + }; let change_addr = payment_address_to_orchard(&shielded_state.keys.default_address); @@ -215,7 +341,280 @@ pub async fn unshield_credits( .await .map_err(|e| format!("Failed to broadcast unshield transition: {e}"))?; - Ok(()) + Ok(spent_nullifiers) +} + +/// Build and broadcast a ShieldFromAssetLock transition (core DASH -> shielded pool via asset lock). +/// +/// Creates an asset lock transaction from wallet UTXOs, broadcasts it, waits for +/// an InstantLock/ChainLock proof, then builds and broadcasts a Type 18 +/// ShieldFromAssetLock state transition that deposits credits directly into the +/// shielded pool. +pub async fn shield_from_asset_lock( + app_context: &Arc, + seed_hash: &WalletSeedHash, + shielded_state: &ShieldedWalletState, + amount_duffs: u64, +) -> Result { + use dash_sdk::dashcore_rpc::RpcApi; + use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; + use dash_sdk::dpp::prelude::AssetLockProof; + use dash_sdk::dpp::shielded::builder::build_shield_from_asset_lock_transition; + use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; + use std::time::Duration; + + let proving_key = crate::context::shielded::get_proving_key(); + + // Platform charges a processing fee on top of the shielded amount, so we must + // inflate the asset lock to cover both the shield amount and the fee. + let platform_fee_credits = app_context + .fee_estimator() + .min_fees() + .address_funding_asset_lock_cost; + // Add a 20% safety buffer to the platform fee estimate + let platform_fee_duffs = (platform_fee_credits / CREDITS_PER_DUFF).saturating_mul(120) / 100; + let asset_lock_duffs = amount_duffs.saturating_add(platform_fee_duffs); + + // Step 1: Create the asset lock transaction + let (asset_lock_transaction, asset_lock_private_key, _asset_lock_address, used_utxos) = { + let wallet_arc = { + let wallets = app_context.wallets.read().unwrap(); + wallets + .get(seed_hash) + .cloned() + .ok_or_else(|| "Wallet not found".to_string())? + }; + + let mut wallet = wallet_arc.write().map_err(|e| e.to_string())?; + + // Try to create the asset lock transaction, reload UTXOs if needed + match wallet.generic_asset_lock_transaction( + app_context.network, + asset_lock_duffs, + false, + Some(app_context.as_ref()), + ) { + Ok((tx, private_key, address, _change, utxos)) => (tx, private_key, address, utxos), + Err(_) => { + // Reload UTXOs and try again + wallet + .reload_utxos( + &app_context + .core_client + .read() + .expect("Core client lock was poisoned"), + app_context.network, + Some(app_context.as_ref()), + ) + .map_err(|e| e.to_string())?; + + let (tx, private_key, address, _change, utxos) = wallet + .generic_asset_lock_transaction( + app_context.network, + asset_lock_duffs, + false, + Some(app_context.as_ref()), + )?; + (tx, private_key, address, utxos) + } + } + }; + + let tx_id = asset_lock_transaction.txid(); + + // Step 2: Register this transaction as waiting for finality + { + let mut proofs = app_context + .transactions_waiting_for_finality + .lock() + .unwrap(); + proofs.insert(tx_id, None); + } + + // Step 3: Broadcast the transaction + app_context + .core_client + .read() + .expect("Core client lock was poisoned") + .send_raw_transaction(&asset_lock_transaction) + .map_err(|e| format!("Failed to broadcast asset lock transaction: {}", e))?; + + // Step 4: Remove used UTXOs from wallet + { + let wallet_arc = { + let wallets = app_context.wallets.read().unwrap(); + wallets + .get(seed_hash) + .cloned() + .ok_or_else(|| "Wallet not found".to_string())? + }; + + let mut wallet = wallet_arc.write().map_err(|e| e.to_string())?; + wallet.utxos.retain(|_, utxo_map| { + utxo_map.retain(|outpoint, _| !used_utxos.contains_key(outpoint)); + !utxo_map.is_empty() + }); + + for utxo in used_utxos.keys() { + app_context + .db + .drop_utxo(utxo, &app_context.network.to_string()) + .map_err(|e| e.to_string())?; + } + + wallet.recalculate_affected_address_balances(&used_utxos, app_context.as_ref())?; + } + + // Step 5: Wait for asset lock proof (InstantLock or ChainLock) with timeout + let asset_lock_proof: AssetLockProof; + let timeout = tokio::time::sleep(Duration::from_secs(300)); // 5 minute timeout + tokio::pin!(timeout); + + loop { + tokio::select! { + _ = &mut timeout => { + if let Ok(mut proofs) = app_context.transactions_waiting_for_finality.try_lock() { + proofs.remove(&tx_id); + } + + if app_context.core_backend_mode() == crate::spv::CoreBackendMode::Rpc + && let Some(wallet_arc) = app_context.wallets.read().ok() + .and_then(|w| w.get(seed_hash).cloned()) + { + let ctx = Arc::clone(app_context); + tokio::task::spawn_blocking(move || { + if let Err(e) = ctx.refresh_wallet_info(wallet_arc) { + tracing::warn!("Failed to auto-refresh wallet after timeout: {}", e); + } + }); + } + + return Err("Timeout waiting for asset lock proof — no InstantLock or ChainLock received within 5 minutes".to_string()); + } + _ = tokio::time::sleep(Duration::from_millis(200)) => { + let proofs = app_context.transactions_waiting_for_finality.lock().unwrap(); + if let Some(Some(proof)) = proofs.get(&tx_id) { + asset_lock_proof = proof.clone(); + break; + } + } + } + } + + // Step 6: Clean up the finality tracking + { + let mut proofs = app_context + .transactions_waiting_for_finality + .lock() + .unwrap(); + proofs.remove(&tx_id); + } + + // Step 7: Build and broadcast the shield-from-asset-lock transition + let sdk = { + let guard = app_context.sdk.read().unwrap(); + guard.clone() + }; + + let recipient = payment_address_to_orchard(&shielded_state.keys.default_address); + + // Shield only the user's requested amount; the extra duffs in the asset lock + // cover the platform processing fee. + let shield_amount_credits = amount_duffs.checked_mul(CREDITS_PER_DUFF).ok_or_else(|| { + format!( + "Overflow converting {} duffs to credits (CREDITS_PER_DUFF = {})", + amount_duffs, CREDITS_PER_DUFF + ) + })?; + + let state_transition = build_shield_from_asset_lock_transition( + &recipient, + shield_amount_credits, + asset_lock_proof, + asset_lock_private_key.inner.as_ref(), + 0, + proving_key, + [0u8; 36], + sdk.version(), + ) + .map_err(|e| format!("Failed to build shield-from-asset-lock transition: {e}"))?; + + state_transition + .broadcast(&sdk, None) + .await + .map_err(|e| format!("Failed to broadcast shield-from-asset-lock transition: {e}"))?; + + Ok(shield_amount_credits) +} + +/// Build and broadcast a ShieldedWithdrawal transition (shielded pool -> core L1 address). +/// +/// Returns the nullifiers of the notes that were spent. +pub async fn shielded_withdrawal( + app_context: &Arc, + _seed_hash: &WalletSeedHash, + shielded_state: &ShieldedWalletState, + amount: u64, + to_core_address: Address, +) -> Result, String> { + let sdk = { + let guard = app_context.sdk.read().unwrap(); + guard.clone() + }; + + let proving_key = get_proving_key(); + + let output_script = CoreScript::from_bytes(to_core_address.script_pubkey().to_bytes()); + + let (spendable_notes, _total_value) = select_notes_for_amount(shielded_state, amount)?; + let spent_nullifiers: Vec = spendable_notes.iter().map(|n| n.nullifier).collect(); + + let (spends, anchor) = { + let tree = shielded_state.commitment_tree.lock().unwrap(); + let spends = spendable_notes + .iter() + .map(|note| { + let merkle_path = tree + .witness(note.position, 0) + .map_err(|e| format!("Failed to get Merkle witness: {e}"))? + .ok_or("No Merkle path available for note")?; + Ok(SpendableNote { + note: note.note, + merkle_path, + }) + }) + .collect::, String>>()?; + + let anchor = tree + .anchor() + .map_err(|e| format!("Failed to get tree anchor: {e}"))?; + (spends, anchor) + }; + + let change_addr = payment_address_to_orchard(&shielded_state.keys.default_address); + + let state_transition = build_shielded_withdrawal_transition( + spends, + amount, + output_script, + 1, // core_fee_per_byte + Pooling::Standard, + &change_addr, + &shielded_state.keys.fvk, + &shielded_state.keys.ask, + anchor, + proving_key, + [0u8; 36], + sdk.version(), + ) + .map_err(|e| format!("Failed to build shielded withdrawal transition: {e}"))?; + + state_transition + .broadcast(&sdk, None) + .await + .map_err(|e| format!("Failed to broadcast shielded withdrawal transition: {e}"))?; + + Ok(spent_nullifiers) } /// Select notes to cover the requested amount using a greedy algorithm. diff --git a/src/backend_task/shielded/mod.rs b/src/backend_task/shielded/mod.rs index 070b8b59e..c48c6987b 100644 --- a/src/backend_task/shielded/mod.rs +++ b/src/backend_task/shielded/mod.rs @@ -4,6 +4,7 @@ pub mod sync; use crate::model::wallet::WalletSeedHash; use dash_sdk::dpp::address_funds::PlatformAddress; +use dash_sdk::dpp::dashcore::Address; #[derive(Debug, Clone, PartialEq)] pub enum ShieldedTask { @@ -18,6 +19,8 @@ pub enum ShieldedTask { seed_hash: WalletSeedHash, amount: u64, from_address: PlatformAddress, + /// When set, use this nonce instead of reading from wallet (for batch parallel mode). + nonce_override: Option, }, /// Private transfer within the shielded pool (Type 16) @@ -38,6 +41,19 @@ pub enum ShieldedTask { /// Check nullifiers to detect spent notes CheckNullifiers { seed_hash: WalletSeedHash }, + /// Shield core DASH directly into the shielded pool via asset lock (Type 18) + ShieldFromAssetLock { + seed_hash: WalletSeedHash, + amount_duffs: u64, + }, + + /// Withdraw from the shielded pool directly to a core L1 address (Type 19) + ShieldedWithdrawal { + seed_hash: WalletSeedHash, + amount: u64, + to_core_address: Address, + }, + /// Warm up the proving key in background (~30s) WarmUpProvingKey, } diff --git a/src/backend_task/shielded/nullifiers.rs b/src/backend_task/shielded/nullifiers.rs index 9d24d9a41..02a4862b3 100644 --- a/src/backend_task/shielded/nullifiers.rs +++ b/src/backend_task/shielded/nullifiers.rs @@ -2,15 +2,13 @@ use crate::context::AppContext; use crate::model::wallet::WalletSeedHash; use crate::model::wallet::shielded::ShieldedWalletState; use dash_sdk::dpp::dashcore::Network; -use dash_sdk::platform::Fetch; -use dash_sdk::query_types::{ShieldedNullifierStatuses, ShieldedNullifiersQuery}; use std::sync::Arc; -/// Check which unspent notes have been spent on-chain by querying their nullifiers. +/// Check which unspent notes have been spent on-chain using the SDK's +/// privacy-preserving nullifier sync. /// -/// For each unspent note, queries the platform for the nullifier status. -/// Notes whose nullifiers are found spent are marked as such in both -/// the in-memory state and the database. +/// The SDK handles full tree scan vs incremental catch-up internally based +/// on the provided `last_sync_height` and `last_sync_timestamp`. pub async fn check_nullifiers( app_context: &Arc, seed_hash: &WalletSeedHash, @@ -24,55 +22,66 @@ pub async fn check_nullifiers( let network_str = network.to_string(); - // Collect nullifiers of unspent notes - let unspent_nullifiers: Vec> = shielded_state + // Collect unspent nullifier bytes for the provider + let unspent_nullifiers: Vec<[u8; 32]> = shielded_state .notes .iter() .filter(|n| !n.is_spent) - .map(|n| n.nullifier.to_bytes().to_vec()) + .map(|n| n.nullifier.to_bytes()) .collect(); if unspent_nullifiers.is_empty() { return Ok(0); } - let query = ShieldedNullifiersQuery(unspent_nullifiers); + let last_height = shielded_state.last_nullifier_sync_height; + let last_timestamp = shielded_state.last_nullifier_sync_timestamp; - let statuses: Option = ShieldedNullifierStatuses::fetch(&sdk, query) - .await - .map_err(|e| format!("Failed to fetch nullifier statuses: {e}"))?; - - let statuses = match statuses { - Some(s) => s.0, - None => return Ok(0), + let last_sync_height = if last_height > 0 { + Some(last_height) + } else { + None + }; + let last_sync_timestamp = if last_timestamp > 0 { + Some(last_timestamp) + } else { + None }; - let mut spent_count = 0u32; - - for status in statuses { - if status.is_spent { - let nullifier_bytes: [u8; 32] = status - .nullifier - .try_into() - .map_err(|_| "Invalid nullifier length")?; - - // Mark as spent in memory - for note in &mut shielded_state.notes { - if !note.is_spent && note.nullifier.to_bytes() == nullifier_bytes { - note.is_spent = true; - spent_count += 1; + let result = sdk + .sync_nullifiers( + &unspent_nullifiers, + None, + last_sync_height, + last_sync_timestamp, + ) + .await + .map_err(|e| format!("Nullifier sync failed: {e}"))?; - // Mark as spent in DB - let _ = app_context.db.mark_shielded_note_spent( - seed_hash, - &nullifier_bytes, - &network_str, - ); - } + // Mark found (spent) nullifiers + let mut spent_count = 0u32; + for nf_bytes in &result.found { + for note in &mut shielded_state.notes { + if !note.is_spent && note.nullifier.to_bytes() == *nf_bytes { + note.is_spent = true; + spent_count += 1; + let _ = app_context + .db + .mark_shielded_note_spent(seed_hash, nf_bytes, &network_str); } } } + // Persist sync height and timestamp + shielded_state.last_nullifier_sync_height = result.new_sync_height; + shielded_state.last_nullifier_sync_timestamp = result.new_sync_timestamp; + let _ = app_context.db.set_nullifier_sync_info( + seed_hash, + &network_str, + result.new_sync_height, + result.new_sync_timestamp, + ); + if spent_count > 0 { shielded_state.recalculate_balance(); } diff --git a/src/backend_task/shielded/sync.rs b/src/backend_task/shielded/sync.rs index 7683f3426..2fb85c2a6 100644 --- a/src/backend_task/shielded/sync.rs +++ b/src/backend_task/shielded/sync.rs @@ -2,33 +2,18 @@ use crate::context::AppContext; use crate::model::wallet::WalletSeedHash; use crate::model::wallet::shielded::{ShieldedNote, ShieldedWalletState}; use dash_sdk::dpp::dashcore::Network; -use dash_sdk::grovedb_commitment_tree::{ - COMPACT_NOTE_SIZE, CompactAction, DashMemo, EphemeralKeyBytes, ExtractedNoteCommitment, Note, - Nullifier, OrchardDomain, Position, PreparedIncomingViewingKey, Retention, - try_compact_note_decryption, -}; -use dash_sdk::platform::Fetch; -use dash_sdk::query_types::{ - ShieldedEncryptedNote, ShieldedEncryptedNotes, ShieldedEncryptedNotesQuery, -}; +use dash_sdk::grovedb_commitment_tree::{Position, Retention}; +use dash_sdk::shielded::sync_shielded_notes; use std::sync::Arc; -const SYNC_BATCH_SIZE: u32 = 1000; +/// Server-enforced chunk size — start_index must be a multiple of this. +const CHUNK_SIZE: u64 = 2048; -/// Minimum size of the `encrypted_note` field from the RPC response. +/// Sync encrypted notes from the platform using the SDK's parallel chunk fetcher. /// -/// The stored value format in GroveDB is: `cmx(32) || nullifier(32) || encrypted_note(216)`. -/// After proof verification splits at 32 bytes, the `encrypted_note` field contains: -/// nullifier(32) || epk(32) || enc_ciphertext(104) || out_ciphertext(80) = 248 bytes. -const MIN_ENCRYPTED_NOTE_LEN: usize = 32 + 32 + COMPACT_NOTE_SIZE; - -/// Sync encrypted notes from the platform's BulkAppendTree. -/// -/// For each encrypted note: -/// 1. Trial-decrypt with the wallet's IVK using compact note decryption -/// 2. If decrypted, compute nullifier and store as ShieldedNote -/// 3. Append ALL cmx values to the ClientCommitmentTree -/// (Retention::Marked for our notes, Retention::Ephemeral for others) +/// On resume the raw `last_synced_index` may fall mid-chunk; we round down to +/// the nearest chunk boundary and re-fetch that partial chunk, skipping any +/// positions already present in the local tree. pub async fn sync_notes( app_context: &Arc, seed_hash: &WalletSeedHash, @@ -42,157 +27,123 @@ pub async fn sync_notes( let network_str = network.to_string(); let prepared_ivk = shielded_state.keys.ivk.prepare(); - let mut new_note_count = 0u32; - let mut start_index = shielded_state.last_synced_index; - let mut checkpoint_id = start_index as u32; - loop { - let query = ShieldedEncryptedNotesQuery { - start_index, - count: SYNC_BATCH_SIZE, - }; + // Round down to nearest chunk boundary so the server accepts the query. + let already_have = shielded_state.last_synced_index; + let aligned_start = (already_have / CHUNK_SIZE) * CHUNK_SIZE; - let notes_result: Option = - ShieldedEncryptedNotes::fetch(&sdk, query) - .await - .map_err(|e| format!("Failed to fetch encrypted notes: {e}"))?; + tracing::info!( + "Starting shielded note sync: last_synced={}, aligned_start={}", + already_have, + aligned_start, + ); - let notes = match notes_result { - Some(n) => n.0, - None => break, - }; + let result = sync_shielded_notes(&sdk, &prepared_ivk, aligned_start, None) + .await + .map_err(|e| format!("Failed to sync shielded notes: {e}"))?; - if notes.is_empty() { - break; - } + tracing::info!( + "Sync complete: total_scanned={}, decrypted={}, next_start_index={}", + result.total_notes_scanned, + result.decrypted_notes.len(), + result.next_start_index, + ); - let batch_len = notes.len() as u64; - - for (i, encrypted_note) in notes.into_iter().enumerate() { - let global_position = start_index + i as u64; - let position = Position::from(global_position); - - let cmx_bytes: [u8; 32] = encrypted_note - .cmx - .clone() - .try_into() - .map_err(|_| "Invalid cmx length")?; - - // Try to trial-decrypt with our IVK - if let Some(note) = try_decrypt_note(&prepared_ivk, &encrypted_note, &cmx_bytes) { - let nullifier = note.nullifier(&shielded_state.keys.fvk); - let value = note.value().inner(); - let note_data = crate::model::wallet::shielded::serialize_note(¬e); - - let shielded_note = ShieldedNote { - note, - position, - cmx: cmx_bytes, - nullifier, - block_height: 0, // Not available from encrypted notes query - is_spent: false, - value, - }; - - // Persist to DB - let nullifier_bytes = nullifier.to_bytes(); - let _ = app_context.db.insert_shielded_note( - seed_hash, - &crate::database::shielded::InsertShieldedNote { - note_data: ¬e_data, - position: global_position, - cmx: &cmx_bytes, - nullifier: &nullifier_bytes, - block_height: 0, - value, - network: &network_str, - }, - ); - - shielded_state.notes.push(shielded_note); - new_note_count += 1; - - // Mark in commitment tree (we need witness for this note) - shielded_state - .commitment_tree - .append(cmx_bytes, Retention::Marked) - .map_err(|e| format!("Failed to append marked note to tree: {e}"))?; - } else { - // Not our note, but still need to track in tree for correct Merkle paths - shielded_state - .commitment_tree - .append(cmx_bytes, Retention::Ephemeral) - .map_err(|e| format!("Failed to append ephemeral note to tree: {e}"))?; - } + // Append notes to the local commitment tree, skipping positions already present. + // all_notes is ordered: aligned_start + i == global position of all_notes[i]. + let mut appended = 0u32; + for (i, raw_note) in result.all_notes.iter().enumerate() { + let global_pos = aligned_start + i as u64; + if global_pos < already_have { + continue; // already appended in a previous sync } - start_index += batch_len; - checkpoint_id += 1; + let cmx_bytes: [u8; 32] = raw_note + .cmx + .as_slice() + .try_into() + .map_err(|_| "Invalid cmx length")?; + + let is_ours = result + .decrypted_notes + .iter() + .any(|dn| dn.position == global_pos); + let retention = if is_ours { + Retention::Marked + } else { + Retention::Ephemeral + }; - // Checkpoint the tree shielded_state .commitment_tree + .lock() + .unwrap() + .append(cmx_bytes, retention) + .map_err(|e| format!("Failed to append note to tree: {e}"))?; + + appended += 1; + } + + if appended > 0 { + let checkpoint_id = result.next_start_index as u32; + shielded_state + .commitment_tree + .lock() + .unwrap() .checkpoint(checkpoint_id) .map_err(|e| format!("Failed to checkpoint tree: {e}"))?; + } - // If we got fewer notes than the batch size, we've caught up - if batch_len < SYNC_BATCH_SIZE as u64 { - break; + // Persist and record decrypted notes that are new (position >= already_have). + let mut new_note_count = 0u32; + for dn in result.decrypted_notes { + if dn.position < already_have { + continue; // already stored in a previous sync } + + // Compute the spending nullifier from our FVK (dn.nullifier is the rho/nf + // field from the compact action, not the spending nullifier). + let nullifier = dn.note.nullifier(&shielded_state.keys.fvk); + let value = dn.note.value().inner(); + + tracing::info!("Note[{}]: DECRYPTED, value={} credits", dn.position, value,); + + let note_data = crate::model::wallet::shielded::serialize_note(&dn.note); + let nullifier_bytes = nullifier.to_bytes(); + + let _ = app_context.db.insert_shielded_note( + seed_hash, + &crate::database::shielded::InsertShieldedNote { + note_data: ¬e_data, + position: dn.position, + cmx: &dn.cmx, + nullifier: &nullifier_bytes, + block_height: 0, + value, + network: &network_str, + }, + ); + + shielded_state.notes.push(ShieldedNote { + note: dn.note, + position: Position::from(dn.position), + cmx: dn.cmx, + nullifier, + block_height: 0, + is_spent: false, + value, + }); + + new_note_count += 1; } - shielded_state.last_synced_index = start_index; + // Store the actual number of notes seen, not the chunk-rounded next_start_index. + // The SDK rounds next_start_index UP to the next 2048 boundary, which would + // make the display show 2048 even when only e.g. 150 notes exist. + // On the next sync we round down again, re-fetch the partial chunk, and skip + // positions already in the tree — so correctness is maintained. + shielded_state.last_synced_index = aligned_start + result.total_notes_scanned; shielded_state.recalculate_balance(); - // Save tree state to DB - let _ = app_context.db.save_shielded_tree_state( - seed_hash, - &[], - shielded_state.last_synced_index, - &network_str, - ); - Ok((new_note_count, shielded_state.shielded_balance)) } - -/// Attempt compact trial decryption on an encrypted note from the RPC. -/// -/// The `encrypted_note.encrypted_note` field contains (after proof verification): -/// `nullifier(32) || epk(32) || enc_ciphertext(104) || out_ciphertext(80)` -/// -/// For compact decryption we only need the first 116 bytes: -/// - nullifier(32): derives Rho via `Rho::from_nf_old(nullifier)` for OrchardDomain -/// - epk(32): ephemeral public key for key agreement -/// - enc_compact(52): first COMPACT_NOTE_SIZE bytes of enc_ciphertext -/// (version + diversifier + value + rseed) -fn try_decrypt_note( - ivk: &PreparedIncomingViewingKey, - encrypted_note: &ShieldedEncryptedNote, - cmx_bytes: &[u8; 32], -) -> Option { - let data = &encrypted_note.encrypted_note; - if data.len() < MIN_ENCRYPTED_NOTE_LEN { - return None; - } - - // Parse nullifier (first 32 bytes) - let nf_bytes: [u8; 32] = data[0..32].try_into().ok()?; - let nf = Nullifier::from_bytes(&nf_bytes).into_option()?; - - // Parse cmx - let cmx = ExtractedNoteCommitment::from_bytes(cmx_bytes).into_option()?; - - // Parse ephemeral public key (next 32 bytes) - let epk_bytes: [u8; 32] = data[32..64].try_into().ok()?; - - // Parse compact ciphertext (first COMPACT_NOTE_SIZE bytes of enc_ciphertext) - let enc_compact: [u8; COMPACT_NOTE_SIZE] = data[64..64 + COMPACT_NOTE_SIZE].try_into().ok()?; - - // Build CompactAction and OrchardDomain for trial decryption - let compact = CompactAction::from_parts(nf, cmx, EphemeralKeyBytes(epk_bytes), enc_compact); - let domain = OrchardDomain::::for_compact_action(&compact); - - // Attempt decryption — returns (Note, PaymentAddress) if this note belongs to us - let (note, _address) = try_compact_note_decryption(&domain, ivk, &compact)?; - Some(note) -} diff --git a/src/backend_task/wallet/fetch_platform_address_balances.rs b/src/backend_task/wallet/fetch_platform_address_balances.rs index d07f7e030..ec72bcbc0 100644 --- a/src/backend_task/wallet/fetch_platform_address_balances.rs +++ b/src/backend_task/wallet/fetch_platform_address_balances.rs @@ -1,28 +1,22 @@ use crate::backend_task::BackendTaskSuccessResult; -use crate::backend_task::wallet::PlatformSyncMode; use crate::context::AppContext; use crate::model::wallet::{ - DerivationPathHelpers, DerivationPathReference, DerivationPathType, Wallet, - WalletAddressProvider, WalletSeedHash, + DerivationPathHelpers, DerivationPathReference, DerivationPathType, WalletAddressProvider, + WalletSeedHash, }; use dash_sdk::RequestSettings; -use dash_sdk::Sdk; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::key_wallet::bip32::DerivationPath; use dash_sdk::platform::address_sync::AddressSyncConfig; use dash_sdk::platform::address_sync::AddressSyncResult; -use std::sync::{Arc, RwLock}; +use std::sync::Arc; impl AppContext { pub(crate) async fn fetch_platform_address_balances( self: &Arc, seed_hash: WalletSeedHash, - sync_mode: PlatformSyncMode, ) -> Result { - // 6 days and 20 hours in seconds (to be safe before 7 days) - const FULL_SYNC_INTERVAL_SECS: u64 = 6 * 24 * 60 * 60 + 20 * 60 * 60; // 590400 seconds - - tracing::info!("Platform address sync start (mode: {:?})", sync_mode); + tracing::info!("Platform address sync start"); let start_time = std::time::Instant::now(); let wallet_arc = { @@ -33,234 +27,100 @@ impl AppContext { .ok_or_else(|| "Wallet not found".to_string())? }; - // Check last full sync time and terminal block from database - let (last_full_sync, stored_checkpoint, last_terminal_block) = self - .db - .get_platform_sync_info(&seed_hash) - .unwrap_or((0, 0, 0)); - - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - - // Determine if we need a full sync based on mode - let needs_full_sync = match sync_mode { - PlatformSyncMode::ForceFull => true, - PlatformSyncMode::TerminalOnly => { - if stored_checkpoint == 0 { - return Err( - "Terminal-only sync requested but no checkpoint exists. Run a full sync first." - .to_string(), - ); - } - false - } - PlatformSyncMode::Auto => { - last_full_sync == 0 - || stored_checkpoint == 0 - || now.saturating_sub(last_full_sync) >= FULL_SYNC_INTERVAL_SECS - } - }; + // Get last sync timestamp from database + let (last_sync_timestamp, last_sync_height) = + self.db.get_platform_sync_info(&seed_hash).unwrap_or((0, 0)); // Create provider (requires wallet to be open for address derivation) - let mut provider = { + let provider = { let wallet = wallet_arc.read().map_err(|e| e.to_string())?; match WalletAddressProvider::new(&wallet, self.network) { - Ok(provider) => provider, + Ok(provider) => provider.with_stored_state(&wallet, self.network, last_sync_height), Err(_) if !wallet.is_open() => { return Err("Wallet is locked. Please unlock it first to refresh.".to_string()); } Err(e) => return Err(e), } }; + let mut provider = provider; - // Sync using SDK's privacy-preserving method + // Sync using SDK's privacy-preserving method (handles both full and incremental) let sdk = { let guard = self.sdk.read().map_err(|e| e.to_string())?; guard.clone() }; - let (_checkpoint_height, highest_block_processed) = if needs_full_sync { - tracing::info!( - "Performing full platform address sync (last sync: {} seconds ago)", - now.saturating_sub(last_full_sync) - ); - - // trunk state query is failing if tree is empty with internal error - // this happens when we don't have any balances yet - // this case most often happens for local network - // so we do not ban addresses in case of failure - // and return empty `AddressSyncResult` - let config = if sdk.network == Network::Regtest { - Some(AddressSyncConfig { - request_settings: RequestSettings { - ban_failed_address: Some(false), - ..Default::default() - }, + let config = if sdk.network == Network::Regtest { + Some(AddressSyncConfig { + request_settings: RequestSettings { + ban_failed_address: Some(false), ..Default::default() - }) - } else { - None - }; - - // Perform the base sync - let base_start = std::time::Instant::now(); - let result = match sdk - .sync_address_balances(&mut provider, config.clone()) - .await - { - Ok(res) => res, - Err(e) if e.to_string().contains("empty tree") => { - tracing::debug!( - "Platform address balance tree is empty. Returning empty sync result." - ); - AddressSyncResult::default() - } - Err(e) => return Err(format!("Failed to sync Platform addresses: {}", e)), - }; - let base_duration = base_start.elapsed(); - - tracing::info!( - "Base sync complete: duration={:?}, found={}, absent={}, checkpoint={}", - base_duration, - result.found.len(), - result.absent.len(), - result.checkpoint_height - ); - - // Apply terminal updates and capture the highest block processed - let terminal_start_height = result.checkpoint_height.max(last_terminal_block); - let terminal_sync_start = std::time::Instant::now(); - let highest_block_processed = self - .apply_recent_balance_changes( - &sdk, - &wallet_arc, - &mut provider, - terminal_start_height, - ) - .await?; - let terminal_sync_duration = terminal_sync_start.elapsed(); - tracing::info!( - "Terminal balance updates complete: duration={:?}, start_height={}, end_height={}", - terminal_sync_duration, - terminal_start_height, - highest_block_processed - ); + }, + ..Default::default() + }) + } else { + None + }; - tracing::info!( - "Full sync complete: duration={:?}, found={}, absent={}, highest_index={:?}, checkpoint_height={}", - start_time.elapsed(), - result.found.len(), - result.absent.len(), - result.highest_found_index, - result.checkpoint_height - ); + let last_ts = if last_sync_timestamp > 0 { + Some(last_sync_timestamp) + } else { + None + }; - // Log the found balances from provider - for (addr, funds) in provider.found_balances() { - use dash_sdk::dpp::address_funds::PlatformAddress; - let platform_addr_str = PlatformAddress::try_from(addr.clone()) - .map(|p| p.to_bech32m_string(self.network)) - .unwrap_or_else(|_| addr.to_string()); - tracing::info!( - "Sync found address: {} with balance: {}, nonce: {}", - platform_addr_str, - funds.balance, - funds.nonce + let result = match sdk + .sync_address_balances(&mut provider, config, last_ts) + .await + { + Ok(res) => res, + Err(e) if e.to_string().contains("empty tree") => { + tracing::debug!( + "Platform address balance tree is empty. Returning empty sync result." ); + AddressSyncResult::default() } + Err(e) => return Err(format!("Failed to sync Platform addresses: {}", e)), + }; - // Save the new full sync timestamp and checkpoint - if let Err(e) = - self.db - .set_platform_sync_info(&seed_hash, now, result.checkpoint_height) - { - tracing::warn!("Failed to save platform sync info: {}", e); - } - - (result.checkpoint_height, highest_block_processed) - } else { - let terminal_only_start = std::time::Instant::now(); - tracing::info!( - "Performing terminal-only platform address sync (last full sync: {} seconds ago, checkpoint={}, last_terminal_block={})", - now.saturating_sub(last_full_sync), - stored_checkpoint, - last_terminal_block - ); - - // Pre-populate provider with LAST SYNCED balances (not current balances) - // This prevents double-counting when proof-verified updates happened after last sync - let mut pre_populated_count = 0; - { - let wallet = wallet_arc.read().map_err(|e| e.to_string())?; - for (core_addr, platform_addr) in wallet.platform_addresses(self.network) { - if let Some(info) = wallet.get_platform_address_info(&core_addr) { - // Only pre-populate if we have a last_full_sync_balance - // (meaning this address was found in a previous full sync) - // This prevents double-counting AddToCredits after app restart - if let Some(full_sync_balance) = info.last_full_sync_balance { - let lookup_addr = platform_addr.to_address_with_network(self.network); - provider.update_balance(&lookup_addr, full_sync_balance); - pre_populated_count += 1; - tracing::debug!( - "Pre-populated balance for {}: {} (from last full sync)", - platform_addr.to_bech32m_string(self.network), - full_sync_balance - ); - } else { - tracing::debug!( - "Skipping pre-population for {} (no last_full_sync_balance, needs full sync)", - platform_addr.to_bech32m_string(self.network) - ); - } - } - } - } - tracing::info!( - "Terminal-only sync setup complete: duration={:?}, pre_populated={} addresses", - terminal_only_start.elapsed(), - pre_populated_count - ); + tracing::info!( + "Sync complete: duration={:?}, found={}, absent={}, highest_index={:?}, checkpoint={}, new_sync_height={}, new_sync_timestamp={}", + start_time.elapsed(), + result.found.len(), + result.absent.len(), + result.highest_found_index, + result.checkpoint_height, + result.new_sync_height, + result.new_sync_timestamp, + ); - // For terminal-only sync, fetch recent balance changes - // Use the higher of checkpoint_height or last_terminal_block to avoid - // re-applying changes we've already processed. - let terminal_start_height = stored_checkpoint.max(last_terminal_block); - let terminal_sync_start = std::time::Instant::now(); - let highest_block_processed = self - .apply_recent_balance_changes( - &sdk, - &wallet_arc, - &mut provider, - terminal_start_height, - ) - .await?; - let terminal_sync_duration = terminal_sync_start.elapsed(); + // Log the found balances from provider + for (addr, funds) in provider.found_balances() { + use dash_sdk::dpp::address_funds::PlatformAddress; + let platform_addr_str = PlatformAddress::try_from(addr.clone()) + .map(|p| p.to_bech32m_string(self.network)) + .unwrap_or_else(|_| addr.to_string()); tracing::info!( - "Terminal balance updates complete: duration={:?}, start_height={}, end_height={}", - terminal_sync_duration, - terminal_start_height, - highest_block_processed + "Sync found address: {} with balance: {}, nonce: {}", + platform_addr_str, + funds.balance, + funds.nonce ); + } - (stored_checkpoint, highest_block_processed) - }; - - // Save the highest block we've processed to avoid re-applying the same changes - if highest_block_processed > last_terminal_block - && let Err(e) = self - .db - .set_last_terminal_block(&seed_hash, highest_block_processed) - { - tracing::warn!("Failed to save last terminal block: {}", e); + // Persist sync state + if let Err(e) = self.db.set_platform_sync_info( + &seed_hash, + result.new_sync_timestamp, + result.new_sync_height, + ) { + tracing::warn!("Failed to save platform sync info: {}", e); } // Apply results to wallet and persist let balances = { let mut wallet = wallet_arc.write().map_err(|e| e.to_string())?; - // Update wallet with synced balances (also updates last_full_sync_balance for next sync) + // Update wallet with synced balances provider.apply_results_to_wallet(&mut wallet); // Persist addresses and balances to database @@ -285,21 +145,19 @@ impl AppContext { } // Persist balance to platform_address_balances table - // Use the nonce from AddressFunds which comes directly from SDK sync - // This is a sync operation, so update last_full_sync_balance if let Err(e) = self.db.set_platform_address_info( &seed_hash, address, funds.balance, funds.nonce, &self.network, - true, // Sync operation - update last_full_sync_balance + true, ) { tracing::warn!("Failed to persist Platform address info: {}", e); } } - // Return balances for result (use nonce from AddressFunds) + // Return balances for result provider .found_balances() .iter() @@ -308,11 +166,9 @@ impl AppContext { }; let addresses_with_balance = provider.found_balances().len(); - let total_duration = start_time.elapsed(); tracing::info!( - "Platform address sync complete: total_duration={:?}, mode={:?}, addresses_with_balance={}", - total_duration, - sync_mode, + "Platform address sync complete: total_duration={:?}, addresses_with_balance={}", + start_time.elapsed(), addresses_with_balance ); @@ -321,241 +177,4 @@ impl AppContext { balances, }) } - - /// Apply recent balance changes (terminal updates) to catch changes after a starting block. - /// - /// The trunk/branch sync provides balances as of a checkpoint (every ~10 minutes). - /// This function fetches balance changes since the starting block to provide - /// more up-to-date balances. - /// - /// Two queries are performed in sequence: - /// 1. RecentCompactedAddressBalanceChanges - merged changes for ranges of blocks - /// 2. RecentAddressBalanceChanges - individual per-block changes for most recent blocks - /// - /// Returns the highest block height processed, or an error if network requests failed. - async fn apply_recent_balance_changes( - &self, - sdk: &Sdk, - wallet_arc: &Arc>, - provider: &mut WalletAddressProvider, - start_height: u64, - ) -> Result { - use dash_sdk::dpp::address_funds::PlatformAddress; - use dash_sdk::dpp::balances::credits::{BlockAwareCreditOperation, CreditOperation}; - use dash_sdk::platform::{ - Fetch, RecentAddressBalanceChangesQuery, RecentCompactedAddressBalanceChangesQuery, - }; - use dash_sdk::query_types::{ - RecentAddressBalanceChanges, RecentCompactedAddressBalanceChanges, - }; - - // The trunk/branch sync provides balances as of the checkpoint height. - // We query for compacted changes starting from that start height, - // then query recent non-compacted changes starting from where compacted ends. - - // Query from start_height + 1 because start_height was already processed - // in the previous sync (last_terminal_block is the highest block we've seen) - let query_from_height = start_height.saturating_add(1); - - tracing::debug!( - "Fetching terminal balance updates from height {} (start_height={})", - query_from_height, - start_height - ); - - // Get the wallet's platform addresses to filter relevant changes - let wallet_platform_addresses: std::collections::HashSet = { - let wallet = match wallet_arc.read() { - Ok(w) => w, - Err(e) => return Err(format!("Failed to read wallet: {}", e)), - }; - wallet - .platform_addresses(self.network) - .into_iter() - .map(|(_, platform_addr)| platform_addr) - .collect() - }; - - let mut updates_applied = 0; - let mut highest_block_seen = start_height; - - // Step 1: Fetch compacted balance changes (merged changes for ranges of blocks) - // Start from query_from_height (start_height + 1) to get changes since the last sync - let compacted_fetch_start = std::time::Instant::now(); - let compacted_query = RecentCompactedAddressBalanceChangesQuery::new(query_from_height); - let compacted_result = tokio::time::timeout( - std::time::Duration::from_secs(30), - RecentCompactedAddressBalanceChanges::fetch(sdk, compacted_query), - ) - .await; - let compacted_duration = compacted_fetch_start.elapsed(); - tracing::info!( - "Compacted balance changes fetch: duration={:?}, from_height={}", - compacted_duration, - query_from_height - ); - let compacted_result = match compacted_result { - Ok(result) => result, - Err(_) => { - return Err("Compacted balance changes fetch timed out after 30s".to_string()); - } - }; - let compacted_changes = match compacted_result { - Ok(Some(changes)) => Some(changes), - Ok(None) => None, - Err(e) => { - return Err(format!("Failed to fetch compacted balance changes: {}", e)); - } - }; - if let Some(compacted_changes) = compacted_changes { - for block_changes in compacted_changes.into_inner() { - // Track the highest block height we've processed - if block_changes.end_block_height > highest_block_seen { - highest_block_seen = block_changes.end_block_height; - } - - for (platform_addr, credit_op) in block_changes.changes { - if wallet_platform_addresses.contains(&platform_addr) { - let core_addr = platform_addr.to_address_with_network(self.network); - let current_balance = provider - .found_balances() - .get(&core_addr) - .map(|funds| funds.balance) - .unwrap_or(0); - - let new_balance = match credit_op { - BlockAwareCreditOperation::SetCredits(credits) => { - tracing::debug!( - "Compacted SetCredits: {} = {}", - platform_addr.to_bech32m_string(self.network), - credits - ); - credits - } - BlockAwareCreditOperation::AddToCreditsOperations(operations) => { - // Only apply credits from blocks at or after our query height - // (since we query from start_height + 1, all results should be valid) - let total_to_add: u64 = operations - .iter() - .filter(|(height, _)| **height >= query_from_height) - .map(|(_, credits)| *credits) - .sum(); - tracing::debug!( - "Compacted AddToCredits: {} current={} + add={} = {}", - platform_addr.to_bech32m_string(self.network), - current_balance, - total_to_add, - current_balance.saturating_add(total_to_add) - ); - current_balance.saturating_add(total_to_add) - } - }; - - if new_balance != current_balance { - provider.update_balance(&core_addr, new_balance); - let addr_str = platform_addr.to_bech32m_string(self.network); - tracing::info!( - "Compacted update: {} balance {} -> {}", - addr_str, - current_balance, - new_balance - ); - updates_applied += 1; - } - } - } - } - } - - // Step 2: Fetch non-compacted balance changes (individual per-block changes) - // Use the highest block height from compacted changes + 1 as the start - let recent_fetch_start = std::time::Instant::now(); - let recent_query = RecentAddressBalanceChangesQuery::new(highest_block_seen + 1); - let recent_result = tokio::time::timeout( - std::time::Duration::from_secs(30), - RecentAddressBalanceChanges::fetch(sdk, recent_query), - ) - .await; - let recent_duration = recent_fetch_start.elapsed(); - tracing::info!( - "Recent balance changes fetch: duration={:?}, from_height={}", - recent_duration, - highest_block_seen + 1 - ); - let recent_result = match recent_result { - Ok(result) => result, - Err(_) => { - return Err("Recent balance changes fetch timed out after 30s".to_string()); - } - }; - let recent_changes = match recent_result { - Ok(Some(changes)) => Some(changes), - Ok(None) => None, - Err(e) => { - return Err(format!("Failed to fetch recent balance changes: {}", e)); - } - }; - if let Some(recent_changes) = recent_changes { - for block_changes in recent_changes.into_inner() { - // Track the block height from non-compacted changes - if block_changes.block_height > highest_block_seen { - highest_block_seen = block_changes.block_height; - } - - for (platform_addr, credit_op) in block_changes.changes { - if wallet_platform_addresses.contains(&platform_addr) { - let core_addr = platform_addr.to_address_with_network(self.network); - let current_balance = provider - .found_balances() - .get(&core_addr) - .map(|funds| funds.balance) - .unwrap_or(0); - - let new_balance = match credit_op { - CreditOperation::SetCredits(credits) => { - tracing::debug!( - "Recent SetCredits: {} = {}", - platform_addr.to_bech32m_string(self.network), - credits - ); - credits - } - CreditOperation::AddToCredits(credits) => { - tracing::debug!( - "Recent AddToCredits: {} current={} + add={} = {}", - platform_addr.to_bech32m_string(self.network), - current_balance, - credits, - current_balance.saturating_add(credits) - ); - current_balance.saturating_add(credits) - } - }; - - if new_balance != current_balance { - provider.update_balance(&core_addr, new_balance); - let addr_str = platform_addr.to_bech32m_string(self.network); - tracing::info!( - "Recent update: {} balance {} -> {}", - addr_str, - current_balance, - new_balance - ); - updates_applied += 1; - } - } - } - } - } - - if updates_applied > 0 { - tracing::info!( - "Applied {} terminal balance updates from recent blocks (up to block {})", - updates_applied, - highest_block_seen - ); - } - - Ok(highest_block_seen) - } } diff --git a/src/backend_task/wallet/fund_platform_address_from_asset_lock.rs b/src/backend_task/wallet/fund_platform_address_from_asset_lock.rs index 02983677c..293c42030 100644 --- a/src/backend_task/wallet/fund_platform_address_from_asset_lock.rs +++ b/src/backend_task/wallet/fund_platform_address_from_asset_lock.rs @@ -1,5 +1,4 @@ use crate::backend_task::BackendTaskSuccessResult; -use crate::backend_task::wallet::PlatformSyncMode; use crate::context::AppContext; use crate::model::wallet::WalletSeedHash; use dash_sdk::dpp::address_funds::PlatformAddress; @@ -137,8 +136,7 @@ impl AppContext { } // Trigger a balance refresh - self.fetch_platform_address_balances(seed_hash, PlatformSyncMode::Auto) - .await?; + self.fetch_platform_address_balances(seed_hash).await?; Ok(BackendTaskSuccessResult::PlatformAddressFunded { seed_hash }) } diff --git a/src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs b/src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs index aa67973ab..2f92964a3 100644 --- a/src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs +++ b/src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs @@ -1,5 +1,4 @@ use crate::backend_task::BackendTaskSuccessResult; -use crate::backend_task::wallet::PlatformSyncMode; use crate::context::AppContext; use crate::model::wallet::WalletSeedHash; use crate::spv::CoreBackendMode; @@ -256,8 +255,7 @@ impl AppContext { .map_err(|e| format!("Failed to fund platform address: {}", e))?; // Step 9: Refresh platform address balances - self.fetch_platform_address_balances(seed_hash, PlatformSyncMode::Auto) - .await?; + self.fetch_platform_address_balances(seed_hash).await?; Ok(BackendTaskSuccessResult::PlatformAddressFunded { seed_hash }) } diff --git a/src/backend_task/wallet/mod.rs b/src/backend_task/wallet/mod.rs index 413dea742..83a227fd7 100644 --- a/src/backend_task/wallet/mod.rs +++ b/src/backend_task/wallet/mod.rs @@ -13,18 +13,6 @@ use dash_sdk::dpp::identity::core_script::CoreScript; use dash_sdk::dpp::prelude::AssetLockProof; use std::collections::BTreeMap; -/// Controls how Platform address balance sync is performed -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum PlatformSyncMode { - /// Automatically decide based on time since last full sync - #[default] - Auto, - /// Force a full sync (queries all addresses) - ForceFull, - /// Only do terminal sync using stored checkpoint (fails if no checkpoint exists) - TerminalOnly, -} - #[derive(Debug, Clone, PartialEq)] pub enum WalletTask { GenerateReceiveAddress { @@ -33,7 +21,6 @@ pub enum WalletTask { /// Fetch Platform address balances and nonces from Platform for a wallet FetchPlatformAddressBalances { seed_hash: WalletSeedHash, - sync_mode: PlatformSyncMode, }, /// Transfer credits between Platform addresses TransferPlatformCredits { diff --git a/src/backend_task/wallet/withdraw_from_platform_address.rs b/src/backend_task/wallet/withdraw_from_platform_address.rs index a12b8948f..74ada060e 100644 --- a/src/backend_task/wallet/withdraw_from_platform_address.rs +++ b/src/backend_task/wallet/withdraw_from_platform_address.rs @@ -1,5 +1,4 @@ use crate::backend_task::BackendTaskSuccessResult; -use crate::backend_task::wallet::PlatformSyncMode; use crate::context::AppContext; use crate::model::wallet::WalletSeedHash; use dash_sdk::dpp::address_funds::PlatformAddress; @@ -57,8 +56,7 @@ impl AppContext { .map_err(|e| format!("Failed to withdraw from Platform address: {}", e))?; // Trigger a balance refresh - self.fetch_platform_address_balances(seed_hash, PlatformSyncMode::Auto) - .await?; + self.fetch_platform_address_balances(seed_hash).await?; Ok(BackendTaskSuccessResult::PlatformAddressWithdrawal { seed_hash }) } diff --git a/src/context/shielded.rs b/src/context/shielded.rs index fba0d088b..1665f521d 100644 --- a/src/context/shielded.rs +++ b/src/context/shielded.rs @@ -4,7 +4,9 @@ use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::shielded::ShieldedTask; use crate::context::AppContext; use crate::model::wallet::shielded::{ShieldedNote, ShieldedWalletState, derive_orchard_keys}; -use dash_sdk::grovedb_commitment_tree::{Nullifier, Position, ProvingKey}; +use dash_sdk::grovedb_commitment_tree::{ + ClientPersistentCommitmentTree, Nullifier, Position, ProvingKey, +}; use std::sync::Arc; static PROVING_KEY: OnceLock = OnceLock::new(); @@ -45,8 +47,9 @@ impl AppContext { seed_hash, amount, from_address, + nonce_override, } => { - self.shield_credits_task(seed_hash, amount, from_address) + self.shield_credits_task(seed_hash, amount, from_address, nonce_override) .await } @@ -71,7 +74,78 @@ impl AppContext { ShieldedTask::CheckNullifiers { seed_hash } => { self.check_nullifiers_task(seed_hash).await } + + ShieldedTask::ShieldFromAssetLock { + seed_hash, + amount_duffs, + } => { + self.shield_from_asset_lock_task(seed_hash, amount_duffs) + .await + } + + ShieldedTask::ShieldedWithdrawal { + seed_hash, + amount, + to_core_address, + } => { + self.shielded_withdrawal_task(seed_hash, amount, to_core_address) + .await + } + } + } + + /// Increment the stored nonce for a platform address after a successful state transition. + /// + /// Updates both the in-memory wallet and the persisted DB record so that + /// subsequent operations (single-shot or batch) read the correct next nonce + /// without needing a full platform-address sync. + pub fn bump_platform_address_nonce( + &self, + seed_hash: &crate::model::wallet::WalletSeedHash, + from_address: &dash_sdk::dpp::address_funds::PlatformAddress, + ) { + let wallets = self.wallets.read().unwrap(); + let wallet_arc = match wallets.get(seed_hash) { + Some(w) => w.clone(), + None => return, + }; + drop(wallets); + + let mut wallet = wallet_arc.write().unwrap(); + // Find the matching entry (platform_address_info is keyed by core Address) + let mut found: Option<(dash_sdk::dpp::dashcore::Address, u64, u32)> = None; + for (core_addr, info) in wallet.platform_address_info.iter_mut() { + if let Ok(pa) = + dash_sdk::dpp::address_funds::PlatformAddress::try_from(core_addr.clone()) + && &pa == from_address + { + info.nonce += 1; + found = Some((core_addr.clone(), info.balance, info.nonce)); + break; + } } + drop(wallet); + + // Persist updated nonce to DB + if let Some((core_addr, balance, new_nonce)) = found { + let _ = self.db.set_platform_address_info( + seed_hash, + &core_addr, + balance, + new_nonce, + &self.network, + false, + ); + } + } + + /// Get the default shielded payment address for a wallet. + pub fn shielded_default_address( + &self, + seed_hash: &crate::model::wallet::WalletSeedHash, + ) -> Option { + let states = self.shielded_states.lock().unwrap(); + states.get(seed_hash).map(|s| s.keys.default_address) } /// Initialize shielded wallet state by deriving ZIP32 keys from the wallet seed. @@ -108,11 +182,38 @@ impl AppContext { let keys = derive_orchard_keys(&seed_bytes, self.network, 0)?; let network_str = self.network.to_string(); - let mut state = ShieldedWalletState::new(keys); - // Tree is always rebuilt from scratch (ClientCommitmentTree has no serde). - // Notes are loaded from DB for instant balance; tree needs full re-sync. - state.last_synced_index = 0; + // Open the persistent commitment tree on the shared DB connection. + // Tables are created automatically if they don't exist. + let commitment_tree = ClientPersistentCommitmentTree::open_on_shared_connection( + self.db.shared_connection(), + 100, + ) + .map_err(|e| format!("Failed to open commitment tree: {e}"))?; + + let mut last_synced_index = 0u64; + + // Resume from persisted tree state if available + if let Ok(Some(pos)) = commitment_tree.max_leaf_position() { + last_synced_index = u64::from(pos) + 1; + } + + let (last_nullifier_sync_height, last_nullifier_sync_timestamp) = self + .db + .get_nullifier_sync_info(&seed_hash, &network_str) + .unwrap_or((0, 0)); + + let mut state = ShieldedWalletState { + keys, + notes: Vec::new(), + commitment_tree: std::sync::Mutex::new(commitment_tree), + last_synced_index, + last_nullifier_sync_height, + last_nullifier_sync_timestamp, + shielded_balance: 0, + last_notes_synced_at: None, + last_nullifiers_synced_at: None, + }; // Load persisted notes from DB and reconstruct Note objects if let Ok(note_rows) = self.db.get_unspent_shielded_notes(&seed_hash, &network_str) { @@ -163,6 +264,10 @@ impl AppContext { ) .await; + if result.is_ok() { + state.last_notes_synced_at = Some(std::time::Instant::now()); + } + // Put state back { let mut states = self.shielded_states.lock().unwrap(); @@ -178,35 +283,38 @@ impl AppContext { } /// Shield credits from a platform address into the shielded pool. + /// + /// Unlike other shielded operations, shield_credits only needs the + /// payment address from the shielded state (no tree or notes access). + /// We read it without removing the state so parallel operations can share it. async fn shield_credits_task( self: &Arc, seed_hash: crate::model::wallet::WalletSeedHash, amount: u64, from_address: dash_sdk::dpp::address_funds::PlatformAddress, + nonce_override: Option, ) -> Result { - let state_ref = { - let mut states = self.shielded_states.lock().unwrap(); - states - .remove(&seed_hash) - .ok_or("Shielded wallet not initialized")? + let default_address = { + let states = self.shielded_states.lock().unwrap(); + let state = states + .get(&seed_hash) + .ok_or("Shielded wallet not initialized")?; + state.keys.default_address }; - let result = crate::backend_task::shielded::bundle::shield_credits( + crate::backend_task::shielded::bundle::shield_credits( self, &seed_hash, - &state_ref, + &default_address, amount, from_address, + nonce_override, + None, ) - .await; + .await?; - // Put state back - { - let mut states = self.shielded_states.lock().unwrap(); - states.insert(seed_hash, state_ref); - } + self.bump_platform_address_nonce(&seed_hash, &from_address); - result?; Ok(BackendTaskSuccessResult::ShieldedCreditsShielded { seed_hash, amount }) } @@ -217,7 +325,7 @@ impl AppContext { amount: u64, recipient_address_bytes: Vec, ) -> Result { - let state = { + let mut state = { let mut states = self.shielded_states.lock().unwrap(); states .remove(&seed_hash) @@ -233,6 +341,11 @@ impl AppContext { ) .await; + // On success, mark the spent notes immediately + if let Ok(ref spent_nullifiers) = result { + self.mark_notes_spent(&seed_hash, &mut state, spent_nullifiers); + } + // Put state back { let mut states = self.shielded_states.lock().unwrap(); @@ -250,7 +363,7 @@ impl AppContext { amount: u64, to_platform_address: dash_sdk::dpp::address_funds::PlatformAddress, ) -> Result { - let state = { + let mut state = { let mut states = self.shielded_states.lock().unwrap(); states .remove(&seed_hash) @@ -266,6 +379,11 @@ impl AppContext { ) .await; + // On success, mark the spent notes immediately + if let Ok(ref spent_nullifiers) = result { + self.mark_notes_spent(&seed_hash, &mut state, spent_nullifiers); + } + // Put state back { let mut states = self.shielded_states.lock().unwrap(); @@ -276,6 +394,76 @@ impl AppContext { Ok(BackendTaskSuccessResult::ShieldedCreditsUnshielded { seed_hash, amount }) } + /// Withdraw credits from the shielded pool to a core L1 address. + async fn shielded_withdrawal_task( + self: &Arc, + seed_hash: crate::model::wallet::WalletSeedHash, + amount: u64, + to_core_address: dash_sdk::dpp::dashcore::Address, + ) -> Result { + let mut state = { + let mut states = self.shielded_states.lock().unwrap(); + states + .remove(&seed_hash) + .ok_or("Shielded wallet not initialized")? + }; + + let result = crate::backend_task::shielded::bundle::shielded_withdrawal( + self, + &seed_hash, + &state, + amount, + to_core_address, + ) + .await; + + if let Ok(ref spent_nullifiers) = result { + self.mark_notes_spent(&seed_hash, &mut state, spent_nullifiers); + } + + { + let mut states = self.shielded_states.lock().unwrap(); + states.insert(seed_hash, state); + } + + result?; + Ok(BackendTaskSuccessResult::ShieldedWithdrawalComplete { seed_hash, amount }) + } + + /// Shield core DASH directly into the shielded pool via asset lock. + async fn shield_from_asset_lock_task( + self: &Arc, + seed_hash: crate::model::wallet::WalletSeedHash, + amount_duffs: u64, + ) -> Result { + let state_ref = { + let mut states = self.shielded_states.lock().unwrap(); + states + .remove(&seed_hash) + .ok_or("Shielded wallet not initialized")? + }; + + let result = crate::backend_task::shielded::bundle::shield_from_asset_lock( + self, + &seed_hash, + &state_ref, + amount_duffs, + ) + .await; + + // Put state back + { + let mut states = self.shielded_states.lock().unwrap(); + states.insert(seed_hash, state_ref); + } + + let credits = result?; + Ok(BackendTaskSuccessResult::ShieldedFromAssetLock { + seed_hash, + amount: credits, + }) + } + /// Check nullifiers to detect spent notes. async fn check_nullifiers_task( self: &Arc, @@ -296,6 +484,10 @@ impl AppContext { ) .await; + if result.is_ok() { + state.last_nullifiers_synced_at = Some(std::time::Instant::now()); + } + // Put state back { let mut states = self.shielded_states.lock().unwrap(); @@ -308,4 +500,26 @@ impl AppContext { spent_count, }) } + + /// Mark notes as spent in both memory and DB after a successful broadcast. + fn mark_notes_spent( + &self, + seed_hash: &crate::model::wallet::WalletSeedHash, + state: &mut ShieldedWalletState, + spent_nullifiers: &[Nullifier], + ) { + let network_str = self.network.to_string(); + for nf in spent_nullifiers { + let nf_bytes = nf.to_bytes(); + for note in &mut state.notes { + if !note.is_spent && note.nullifier.to_bytes() == nf_bytes { + note.is_spent = true; + let _ = self + .db + .mark_shielded_note_spent(seed_hash, &nf_bytes, &network_str); + } + } + } + state.recalculate_balance(); + } } diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index 8d1117bdd..3883826e9 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -244,15 +244,14 @@ impl AppContext { // Update in-memory wallet state wallet.set_platform_address_info(core_addr.clone(), info.balance, info.nonce); - // Update database (not a sync operation - preserve last_full_sync_balance - // so the next terminal sync can correctly apply any pending AddToCredits) + // Update database if let Err(e) = self.db.set_platform_address_info( &seed_hash, &core_addr, info.balance, info.nonce, &self.network, - false, // Not a sync operation + false, ) { tracing::warn!("Failed to store Platform address info in database: {}", e); } diff --git a/src/database/initialization.rs b/src/database/initialization.rs index 3c5331299..e6e5e19ad 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -4,7 +4,7 @@ use rusqlite::{Connection, params}; use std::fs; use std::path::Path; -pub const DEFAULT_DB_VERSION: u16 = 28; +pub const DEFAULT_DB_VERSION: u16 = 30; pub const DEFAULT_NETWORK: &str = "dash"; @@ -51,6 +51,12 @@ impl Database { fn apply_version_changes(&self, version: u16, tx: &Connection) -> rusqlite::Result<()> { match version { + 30 => { + self.add_nullifier_sync_timestamp_column(tx)?; + } + 29 => { + self.create_shielded_wallet_meta_table(tx)?; + } 28 => { self.create_shielded_tables(tx)?; } @@ -511,6 +517,7 @@ impl Database { // Initialize shielded pool tables self.create_shielded_tables(&conn)?; + self.create_shielded_wallet_meta_table(&conn)?; Ok(()) } diff --git a/src/database/mod.rs b/src/database/mod.rs index 4742cc37f..6e5e747b7 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -19,7 +19,7 @@ mod wallet; use dash_sdk::dpp::dashcore::Network; use rusqlite::{Connection, Params}; -use std::sync::Mutex; +use std::sync::{Arc, Mutex}; /// Error indicating a corrupted data blob in the database. /// @@ -42,17 +42,25 @@ impl From for rusqlite::Error { #[derive(Debug)] pub struct Database { - conn: Mutex, + conn: Arc>, } impl Database { pub fn new>(path: P) -> rusqlite::Result { let conn = Connection::open(path)?; Ok(Self { - conn: Mutex::new(conn), + conn: Arc::new(Mutex::new(conn)), }) } + /// Get a shared reference to the underlying connection. + /// + /// Used by `ClientPersistentCommitmentTree` to share the same SQLite + /// connection for the shielded commitment tree tables. + pub fn shared_connection(&self) -> Arc> { + self.conn.clone() + } + pub fn execute(&self, sql: &str, params: P) -> rusqlite::Result { let conn = self.conn.lock().unwrap(); conn.execute(sql, params) @@ -164,10 +172,13 @@ impl Database { rusqlite::params![&network_str], )?; - tx.execute( - "DELETE FROM shielded_tree_state WHERE network = ?1", - rusqlite::params![&network_str], - )?; + // Clear commitment tree tables (persistent shielded tree data). + // These tables are created by grovedb on first use, so they may not + // exist yet — ignore errors from missing tables. + let _ = tx.execute("DELETE FROM commitment_tree_shards", []); + let _ = tx.execute("DELETE FROM commitment_tree_cap", []); + let _ = tx.execute("DELETE FROM commitment_tree_checkpoints", []); + let _ = tx.execute("DELETE FROM commitment_tree_checkpoint_marks_removed", []); tx.commit() } diff --git a/src/database/shielded.rs b/src/database/shielded.rs index 5b6b86fcd..9e0a3e5a4 100644 --- a/src/database/shielded.rs +++ b/src/database/shielded.rs @@ -28,17 +28,6 @@ impl Database { [], )?; - conn.execute( - "CREATE TABLE IF NOT EXISTS shielded_tree_state ( - wallet_seed_hash BLOB NOT NULL, - network TEXT NOT NULL, - tree_data BLOB NOT NULL, - last_synced_index INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (wallet_seed_hash, network) - )", - [], - )?; - Ok(()) } @@ -160,55 +149,131 @@ impl Database { ) } - /// Save the serialized commitment tree state for a wallet. - pub fn save_shielded_tree_state( + /// Delete all shielded notes for a wallet (used by resync). + pub fn delete_shielded_notes( &self, wallet_seed_hash: &WalletSeedHash, - tree_data: &[u8], - last_synced_index: u64, network: &str, - ) -> rusqlite::Result<()> { + ) -> rusqlite::Result { let conn = self.conn.lock().unwrap(); conn.execute( - "INSERT INTO shielded_tree_state (wallet_seed_hash, network, tree_data, last_synced_index) - VALUES (?1, ?2, ?3, ?4) - ON CONFLICT(wallet_seed_hash, network) - DO UPDATE SET tree_data = excluded.tree_data, last_synced_index = excluded.last_synced_index", - params![ - wallet_seed_hash.as_slice(), - network, - tree_data, - last_synced_index as i64, - ], + "DELETE FROM shielded_notes WHERE wallet_seed_hash = ?1 AND network = ?2", + params![wallet_seed_hash.as_slice(), network], + ) + } + + /// Clear all commitment tree SQLite tables (used by resync). + /// + /// The `ClientPersistentCommitmentTree` stores its shards, caps, and + /// checkpoints in `commitment_tree_*` tables. This deletes all rows so a + /// fresh tree can be opened on the same connection. + pub fn clear_commitment_tree_tables(&self) -> rusqlite::Result<()> { + let conn = self.conn.lock().unwrap(); + // Tables are created by grovedb on first use; ignore errors if missing. + let _ = conn.execute("DELETE FROM commitment_tree_shards", []); + let _ = conn.execute("DELETE FROM commitment_tree_cap", []); + let _ = conn.execute("DELETE FROM commitment_tree_checkpoints", []); + let _ = conn.execute("DELETE FROM commitment_tree_checkpoint_marks_removed", []); + Ok(()) + } + + /// Create the shielded_wallet_meta table (v29 migration). + pub(crate) fn create_shielded_wallet_meta_table( + &self, + conn: &Connection, + ) -> rusqlite::Result<()> { + conn.execute( + "CREATE TABLE IF NOT EXISTS shielded_wallet_meta ( + wallet_seed_hash BLOB NOT NULL, + network TEXT NOT NULL, + last_nullifier_sync_height INTEGER NOT NULL DEFAULT 0, + last_nullifier_sync_timestamp INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (wallet_seed_hash, network) + )", + [], + )?; + Ok(()) + } + + /// Migration: Add last_nullifier_sync_timestamp column (v30). + pub(crate) fn add_nullifier_sync_timestamp_column( + &self, + conn: &Connection, + ) -> rusqlite::Result<()> { + let table_exists: bool = conn.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='shielded_wallet_meta'", + [], + |row| row.get::<_, i32>(0).map(|count| count > 0), )?; + + if table_exists { + let has_column: bool = conn + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('shielded_wallet_meta') WHERE name='last_nullifier_sync_timestamp'", + [], + |row| row.get::<_, i32>(0).map(|count| count > 0), + ) + .unwrap_or(false); + + if !has_column { + conn.execute( + "ALTER TABLE shielded_wallet_meta ADD COLUMN last_nullifier_sync_timestamp INTEGER NOT NULL DEFAULT 0", + [], + )?; + } + } + Ok(()) } - /// Load the commitment tree state for a wallet. - pub fn load_shielded_tree_state( + /// Get the last nullifier sync height and timestamp for a wallet on a given network. + pub fn get_nullifier_sync_info( &self, wallet_seed_hash: &WalletSeedHash, network: &str, - ) -> rusqlite::Result, u64)>> { + ) -> Result<(u64, u64), String> { let conn = self.conn.lock().unwrap(); let result = conn.query_row( - "SELECT tree_data, last_synced_index FROM shielded_tree_state + "SELECT last_nullifier_sync_height, last_nullifier_sync_timestamp FROM shielded_wallet_meta WHERE wallet_seed_hash = ?1 AND network = ?2", params![wallet_seed_hash.as_slice(), network], |row| { - let tree_data: Vec = row.get(0)?; - let last_synced_index: i64 = row.get(1)?; - Ok((tree_data, last_synced_index as u64)) + let height: i64 = row.get(0)?; + let timestamp: i64 = row.get(1)?; + Ok((height as u64, timestamp as u64)) }, ); - match result { - Ok(data) => Ok(Some(data)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(e), + Ok(info) => Ok(info), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok((0, 0)), + Err(e) => Err(format!("Failed to get nullifier sync info: {e}")), } } + /// Set the last nullifier sync height and timestamp for a wallet on a given network. + pub fn set_nullifier_sync_info( + &self, + wallet_seed_hash: &WalletSeedHash, + network: &str, + height: u64, + timestamp: u64, + ) -> Result<(), String> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT OR REPLACE INTO shielded_wallet_meta + (wallet_seed_hash, network, last_nullifier_sync_height, last_nullifier_sync_timestamp) + VALUES (?1, ?2, ?3, ?4)", + params![ + wallet_seed_hash.as_slice(), + network, + height as i64, + timestamp as i64 + ], + ) + .map_err(|e| format!("Failed to set nullifier sync info: {e}"))?; + Ok(()) + } + /// Get total shielded balance (sum of unspent note values) for a wallet. pub fn get_shielded_balance( &self, diff --git a/src/database/wallet.rs b/src/database/wallet.rs index 77afe5f1d..9cb8ec61b 100644 --- a/src/database/wallet.rs +++ b/src/database/wallet.rs @@ -853,27 +853,20 @@ impl Database { ); // Load platform address info for each wallet (using existing connection to avoid deadlock) let mut platform_stmt = conn.prepare( - "SELECT seed_hash, address, balance, nonce, last_full_sync_balance FROM platform_address_balances WHERE network = ?", + "SELECT seed_hash, address, balance, nonce FROM platform_address_balances WHERE network = ?", )?; let platform_rows = platform_stmt.query_map([network_str.clone()], |row| { let seed_hash: Vec = row.get(0)?; let address_str: String = row.get(1)?; let balance: i64 = row.get(2)?; let nonce: i64 = row.get(3)?; - let last_full_sync_balance: Option = row.get(4)?; let seed_hash_array: [u8; 32] = seed_hash.try_into().expect("Seed hash should be 32 bytes"); - Ok(( - seed_hash_array, - address_str, - balance as u64, - nonce as u32, - last_full_sync_balance.map(|b| b as u64), - )) + Ok((seed_hash_array, address_str, balance as u64, nonce as u32)) })?; for row in platform_rows { - if let Ok((seed_hash, address_str, balance, nonce, last_full_sync_balance)) = row + if let Ok((seed_hash, address_str, balance, nonce)) = row && let Some(wallet) = wallets_map.get_mut(&seed_hash) && let Ok(address) = Address::::from_str(&address_str) { @@ -889,13 +882,7 @@ impl Database { wallet.platform_address_info.insert( canonical_address, - crate::model::wallet::PlatformAddressInfo { - balance, - nonce, - // Use the stored last_full_sync_balance from the database - // This is the balance from the last FULL sync checkpoint, not including terminal updates - last_full_sync_balance, - }, + crate::model::wallet::PlatformAddressInfo { balance, nonce }, ); } } @@ -905,11 +892,6 @@ impl Database { } /// Store or update Platform address balance and nonce. - /// - /// When `is_sync_operation` is true, also updates `last_full_sync_balance` to the current - /// balance. This should be true for sync operations (full or terminal) and false for - /// internal updates (e.g., after a transfer completes), so that subsequent terminal syncs - /// can correctly apply any pending AddToCredits. pub fn set_platform_address_info( &self, seed_hash: &[u8; 32], @@ -917,7 +899,7 @@ impl Database { balance: u64, nonce: u32, network: &Network, - is_sync_operation: bool, + _is_sync_operation: bool, ) -> rusqlite::Result<()> { let network_str = network.to_string(); let canonical_address = Wallet::canonical_address(address, *network); @@ -927,49 +909,23 @@ impl Database { .unwrap_or_default() .as_secs() as i64; - if is_sync_operation { - // Sync operation: update both balance and last_full_sync_balance - // last_full_sync_balance becomes the baseline for pre-population in the next sync - self.execute( - "INSERT INTO platform_address_balances - (seed_hash, address, balance, nonce, network, updated_at, last_full_sync_balance) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(seed_hash, address, network) DO UPDATE SET - balance = excluded.balance, - nonce = excluded.nonce, - updated_at = excluded.updated_at, - last_full_sync_balance = excluded.last_full_sync_balance", - params![ - seed_hash, - address_str, - balance as i64, - nonce as i64, - network_str, - updated_at, - balance as i64 - ], - )?; - } else { - // Internal update (e.g., after transfer): update balance but preserve last_full_sync_balance - // This ensures the next terminal sync correctly applies any pending AddToCredits - self.execute( - "INSERT INTO platform_address_balances - (seed_hash, address, balance, nonce, network, updated_at, last_full_sync_balance) - VALUES (?, ?, ?, ?, ?, ?, NULL) - ON CONFLICT(seed_hash, address, network) DO UPDATE SET - balance = excluded.balance, - nonce = excluded.nonce, - updated_at = excluded.updated_at", - params![ - seed_hash, - address_str, - balance as i64, - nonce as i64, - network_str, - updated_at - ], - )?; - } + self.execute( + "INSERT INTO platform_address_balances + (seed_hash, address, balance, nonce, network, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(seed_hash, address, network) DO UPDATE SET + balance = excluded.balance, + nonce = excluded.nonce, + updated_at = excluded.updated_at", + params![ + seed_hash, + address_str, + balance as i64, + nonce as i64, + network_str, + updated_at + ], + )?; Ok(()) } @@ -1090,49 +1046,31 @@ impl Database { Ok(deleted) } - /// Get the last platform full sync timestamp, checkpoint height, and last terminal block for a wallet - /// Returns (last_sync_timestamp, checkpoint_height, last_terminal_block) or (0, 0, 0) if not set - pub fn get_platform_sync_info( - &self, - seed_hash: &[u8; 32], - ) -> rusqlite::Result<(u64, u64, u64)> { + /// Get the last platform sync timestamp and sync height for a wallet. + /// Returns (last_sync_timestamp, last_sync_height) or (0, 0) if not set. + pub fn get_platform_sync_info(&self, seed_hash: &[u8; 32]) -> rusqlite::Result<(u64, u64)> { let conn = self.conn.lock().unwrap(); conn.query_row( - "SELECT last_platform_full_sync, last_platform_sync_checkpoint, COALESCE(last_terminal_block, 0) FROM wallet WHERE seed_hash = ?", + "SELECT last_platform_full_sync, last_platform_sync_checkpoint FROM wallet WHERE seed_hash = ?", params![seed_hash], |row| { let last_sync: i64 = row.get(0)?; - let checkpoint: i64 = row.get(1)?; - let last_terminal: i64 = row.get(2)?; - Ok((last_sync as u64, checkpoint as u64, last_terminal as u64)) + let sync_height: i64 = row.get(1)?; + Ok((last_sync as u64, sync_height as u64)) }, ) } - /// Set the last platform full sync timestamp and checkpoint height for a wallet - /// Also resets last_terminal_block to 0 since a new full sync was performed + /// Set the platform sync timestamp and sync height for a wallet. pub fn set_platform_sync_info( &self, seed_hash: &[u8; 32], last_sync_timestamp: u64, - checkpoint_height: u64, - ) -> rusqlite::Result<()> { - self.execute( - "UPDATE wallet SET last_platform_full_sync = ?, last_platform_sync_checkpoint = ?, last_terminal_block = 0 WHERE seed_hash = ?", - params![last_sync_timestamp as i64, checkpoint_height as i64, seed_hash], - )?; - Ok(()) - } - - /// Update the last terminal block height after processing terminal balance updates - pub fn set_last_terminal_block( - &self, - seed_hash: &[u8; 32], - last_terminal_block: u64, + sync_height: u64, ) -> rusqlite::Result<()> { self.execute( - "UPDATE wallet SET last_terminal_block = ? WHERE seed_hash = ?", - params![last_terminal_block as i64, seed_hash], + "UPDATE wallet SET last_platform_full_sync = ?, last_platform_sync_checkpoint = ? WHERE seed_hash = ?", + params![last_sync_timestamp as i64, sync_height as i64, seed_hash], )?; Ok(()) } @@ -1480,12 +1418,11 @@ mod tests { } // Initial sync info should be zeros - let (last_sync, checkpoint, last_terminal) = db + let (last_sync, sync_height) = db .get_platform_sync_info(&seed_hash) .expect("Failed to get platform sync info"); assert_eq!(last_sync, 0); - assert_eq!(checkpoint, 0); - assert_eq!(last_terminal, 0); + assert_eq!(sync_height, 0); // Set sync info let timestamp = 1700000000u64; @@ -1493,21 +1430,11 @@ mod tests { db.set_platform_sync_info(&seed_hash, timestamp, height) .expect("Failed to set platform sync info"); - let (last_sync, checkpoint, last_terminal) = db + let (last_sync, sync_height) = db .get_platform_sync_info(&seed_hash) .expect("Failed to get platform sync info"); assert_eq!(last_sync, timestamp); - assert_eq!(checkpoint, height); - assert_eq!(last_terminal, 0); // Reset to 0 by set_platform_sync_info - - // Set last terminal block - db.set_last_terminal_block(&seed_hash, 100500) - .expect("Failed to set last terminal block"); - - let (_, _, last_terminal) = db - .get_platform_sync_info(&seed_hash) - .expect("Failed to get platform sync info"); - assert_eq!(last_terminal, 100500); + assert_eq!(sync_height, height); } #[test] diff --git a/src/model/wallet/asset_lock_transaction.rs b/src/model/wallet/asset_lock_transaction.rs index fbc73f1fb..a189e5011 100644 --- a/src/model/wallet/asset_lock_transaction.rs +++ b/src/model/wallet/asset_lock_transaction.rs @@ -145,19 +145,44 @@ impl Wallet { let asset_lock_public_key = private_key.public_key(&secp); let one_time_key_hash = asset_lock_public_key.pubkey_hash(); - let fee = 3_000; - let (utxos, change_option) = self - .take_unspent_utxos_for(amount, fee, allow_take_fee_from_amount) + // Use a small initial estimate to select UTXOs; we recalculate the fee + // below based on the actual number of inputs. + let initial_fee_estimate = 3_000u64; + + let (utxos, initial_change_option) = self + .take_unspent_utxos_for(amount, initial_fee_estimate, allow_take_fee_from_amount) .ok_or("take_unspent_utxos_for() returned None".to_string())?; - let actual_amount = if change_option.is_none() && allow_take_fee_from_amount { - // The amount has been adjusted by taking the fee from the amount - // Calculate the adjusted amount based on the total value of the UTXOs minus the fee - let total_input_value: u64 = utxos.iter().map(|(_, (tx_out, _))| tx_out.value).sum(); - total_input_value - fee + // Calculate fee based on actual transaction size so we always meet the + // min relay fee (1 duff/byte = 1000 duffs/kB). + // Sizes: P2PKH input ~148 B, output ~34 B, header ~10 B, payload ~60 B. + let num_inputs = utxos.len(); + let has_initial_change = initial_change_option.is_some(); + let num_outputs = 1 + if has_initial_change { 1 } else { 0 }; + let estimated_size = 10 + (num_inputs * 148) + (num_outputs * 34) + 60; + let fee = std::cmp::max(initial_fee_estimate, estimated_size as u64); + + // Recalculate amount and change with the real fee + let total_input_value: u64 = utxos.iter().map(|(_, (tx_out, _))| tx_out.value).sum(); + + let (actual_amount, change_option) = if total_input_value < amount + fee { + if allow_take_fee_from_amount { + // Deduct the fee shortfall from the output amount + let adjusted = total_input_value.saturating_sub(fee); + if adjusted == 0 { + return Err("Insufficient funds for transaction fee".to_string()); + } + (adjusted, None) + } else { + return Err(format!( + "Insufficient funds: need {} + {} fee, have {}", + amount, fee, total_input_value + )); + } } else { - amount + let change = total_input_value - amount - fee; + (amount, if change > 0 { Some(change) } else { None }) }; let payload_output = TxOut { diff --git a/src/model/wallet/mod.rs b/src/model/wallet/mod.rs index b972640ab..247389b54 100644 --- a/src/model/wallet/mod.rs +++ b/src/model/wallet/mod.rs @@ -295,10 +295,6 @@ impl PartialEq for WalletArcRef { pub struct PlatformAddressInfo { pub balance: Credits, pub nonce: AddressNonce, - /// Balance recorded at the last sync checkpoint. Updated by `set_platform_address_info_from_sync` - /// during both full and terminal syncs; preserved by `set_platform_address_info` during internal - /// updates (e.g., after transfers) to avoid double-counting AddToCredits on subsequent syncs. - pub last_full_sync_balance: Option, } #[derive(Debug, Clone, PartialEq)] @@ -1917,97 +1913,52 @@ impl Wallet { None } - /// Update Platform address info (balance and nonce) + /// Update Platform address info (balance and nonce). /// - /// This method handles the case where the same platform address may be represented - /// by different Address objects. It normalizes by comparing PlatformAddress bytes - /// and removes any duplicate entries before inserting. + /// Handles canonical address deduplication: if the same platform address is + /// stored under a different `Address` key, the duplicate is removed first. pub fn set_platform_address_info( &mut self, address: Address, balance: Credits, nonce: AddressNonce, ) { - // Convert the incoming address to PlatformAddress for canonical comparison - let (keys_to_remove, last_full_sync_balance) = - if let Ok(platform_addr) = PlatformAddress::try_from(address.clone()) { - let canonical_bytes = platform_addr.to_bytes(); - - // First, find last_full_sync_balance from any canonical-equivalent entry - // (must be done BEFORE removing duplicates) - let last_full_sync_balance = - self.platform_address_info - .iter() - .find_map(|(existing_addr, info)| { - if let Ok(existing_platform) = - PlatformAddress::try_from(existing_addr.clone()) - && existing_platform.to_bytes() == canonical_bytes - { - return info.last_full_sync_balance; - } - None - }); - - // Find duplicate entries to remove (same platform address, different key) - let keys_to_remove: Vec
= self - .platform_address_info - .keys() - .filter(|existing_addr| { - if let Ok(existing_platform) = - PlatformAddress::try_from((*existing_addr).clone()) - { - existing_platform.to_bytes() == canonical_bytes - && *existing_addr != &address - } else { - false - } - }) - .cloned() - .collect(); - - (keys_to_remove, last_full_sync_balance) - } else { - // Fallback: try direct lookup if canonical conversion fails - let last_full_sync_balance = self - .platform_address_info - .get(&address) - .and_then(|info| info.last_full_sync_balance); - (vec![], last_full_sync_balance) - }; + // Remove duplicate entries for the same canonical platform address + if let Ok(platform_addr) = PlatformAddress::try_from(address.clone()) { + let canonical_bytes = platform_addr.to_bytes(); + let keys_to_remove: Vec
= self + .platform_address_info + .keys() + .filter(|existing_addr| { + if let Ok(existing_platform) = + PlatformAddress::try_from((*existing_addr).clone()) + { + existing_platform.to_bytes() == canonical_bytes + && *existing_addr != &address + } else { + false + } + }) + .cloned() + .collect(); - // Remove duplicate entries - for key in keys_to_remove { - self.platform_address_info.remove(&key); + for key in keys_to_remove { + self.platform_address_info.remove(&key); + } } - self.platform_address_info.insert( - address, - PlatformAddressInfo { - balance, - nonce, - last_full_sync_balance, - }, - ); + self.platform_address_info + .insert(address, PlatformAddressInfo { balance, nonce }); } - /// Set platform address info from a sync operation. - /// Always updates `last_full_sync_balance` to the current balance, as this becomes - /// the baseline for pre-population in the next terminal sync. + /// Set platform address info from a sync operation (same as `set_platform_address_info`). pub fn set_platform_address_info_from_sync( &mut self, address: Address, balance: Credits, nonce: AddressNonce, ) { - self.platform_address_info.insert( - address, - PlatformAddressInfo { - balance, - nonce, - // Always update to current balance - this is the baseline for next sync - last_full_sync_balance: Some(balance), - }, - ); + self.set_platform_address_info(address, balance, nonce); } /// Get the private key for a Platform address @@ -2166,6 +2117,10 @@ pub struct WalletAddressProvider { highest_found: Option, /// Results: address -> balance for addresses found with balance found_balances: BTreeMap, + /// Known balances from previous sync for incremental catch-up + stored_balances: Vec<(AddressIndex, AddressKey, AddressFunds)>, + /// Last sync height from previous sync for incremental catch-up + stored_sync_height: u64, } impl WalletAddressProvider { @@ -2201,6 +2156,8 @@ impl WalletAddressProvider { resolved: BTreeSet::new(), highest_found: None, found_balances: BTreeMap::new(), + stored_balances: Vec::new(), + stored_sync_height: 0, }; // Bootstrap initial addresses (0 to gap_limit - 1) @@ -2311,6 +2268,41 @@ impl WalletAddressProvider { } } + /// Populate stored balances and sync height from a wallet's known state. + /// + /// Call this after construction to enable incremental catch-up. + /// The SDK uses `current_balances()` as the baseline and `last_sync_height()` + /// as the starting block for applying delta operations. + pub fn with_stored_state( + mut self, + wallet: &Wallet, + network: Network, + last_sync_height: u64, + ) -> Self { + self.stored_sync_height = last_sync_height; + + // Populate stored_balances from wallet's known platform addresses + for (core_addr, info) in &wallet.platform_address_info { + // Find the matching pending address to get the index and key + for (index, (key, pending_addr)) in &self.pending { + let canonical = Wallet::canonical_address(pending_addr, network); + if &canonical == core_addr { + self.stored_balances.push(( + *index, + key.clone(), + AddressFunds { + balance: info.balance, + nonce: info.nonce, + }, + )); + break; + } + } + } + + self + } + /// Derive a Platform address at the given index. fn derive_address_at_index( &self, @@ -2431,6 +2423,14 @@ impl AddressProvider for WalletAddressProvider { fn highest_found_index(&self) -> Option { self.highest_found } + + fn current_balances(&self) -> Vec<(AddressIndex, AddressKey, AddressFunds)> { + self.stored_balances.clone() + } + + fn last_sync_height(&self) -> u64 { + self.stored_sync_height + } } #[cfg(test)] @@ -2782,7 +2782,6 @@ mod tests { PlatformAddressInfo { balance: 1_000_000, nonce: 0, - last_full_sync_balance: None, }, ); wallet.platform_address_info.insert( @@ -2790,7 +2789,6 @@ mod tests { PlatformAddressInfo { balance: 2_000_000, nonce: 1, - last_full_sync_balance: None, }, ); @@ -2807,24 +2805,19 @@ mod tests { let info = wallet.platform_address_info.get(&addr).unwrap(); assert_eq!(info.balance, 500_000); assert_eq!(info.nonce, 3); - assert_eq!(info.last_full_sync_balance, Some(500_000)); } #[test] - fn test_set_platform_address_info_preserves_sync_balance() { + fn test_set_platform_address_info_update() { let mut wallet = test_wallet(); let addr = test_address(1); - // First set via sync (establishes last_full_sync_balance) wallet.set_platform_address_info_from_sync(addr.clone(), 500_000, 3); - - // Then update via non-sync (should preserve last_full_sync_balance) wallet.set_platform_address_info(addr.clone(), 600_000, 4); let info = wallet.platform_address_info.get(&addr).unwrap(); assert_eq!(info.balance, 600_000); assert_eq!(info.nonce, 4); - assert_eq!(info.last_full_sync_balance, Some(500_000)); } #[test] @@ -2837,7 +2830,6 @@ mod tests { PlatformAddressInfo { balance: 100_000, nonce: 1, - last_full_sync_balance: None, }, ); diff --git a/src/model/wallet/shielded.rs b/src/model/wallet/shielded.rs index e006b630e..0c57c0647 100644 --- a/src/model/wallet/shielded.rs +++ b/src/model/wallet/shielded.rs @@ -1,9 +1,10 @@ use dash_sdk::dpp::dashcore::Network; use dash_sdk::grovedb_commitment_tree::RandomSeed; use dash_sdk::grovedb_commitment_tree::{ - ClientCommitmentTree, FullViewingKey, IncomingViewingKey, Note, NoteValue, Nullifier, + ClientPersistentCommitmentTree, FullViewingKey, IncomingViewingKey, Note, NoteValue, Nullifier, OutgoingViewingKey, PaymentAddress, Position, Rho, Scope, SpendAuthorizingKey, SpendingKey, }; +use std::sync::Mutex; use zip32::AccountId; /// Dash coin types per BIP44 @@ -79,18 +80,31 @@ pub struct ShieldedNote { /// Per-wallet shielded state, initialized lazily when the shielded tab is first opened. /// -/// Manual `Debug` impl because `ClientCommitmentTree` does not implement `Debug`. +/// Manual `Debug` impl because `ClientPersistentCommitmentTree` does not implement `Debug`. pub struct ShieldedWalletState { /// ZIP32-derived Orchard keys (requires wallet to be unlocked) pub keys: OrchardKeySet, /// All tracked shielded notes pub notes: Vec, - /// Client-side Sinsemilla commitment tree for witness generation - pub commitment_tree: ClientCommitmentTree, + /// Client-side Sinsemilla commitment tree for witness generation (SQLite-backed). + /// + /// Wrapped in `Mutex` because `ClientPersistentCommitmentTree` contains a + /// SQLite `Connection` which is `!Sync`. The mutex makes + /// `ShieldedWalletState` `Sync` so `&ShieldedWalletState` is `Send` across + /// `.await` points in tokio tasks. + pub commitment_tree: Mutex, /// Last note global position synced from platform pub last_synced_index: u64, + /// Block height up to which nullifier sync has been completed + pub last_nullifier_sync_height: u64, + /// Timestamp of last nullifier sync + pub last_nullifier_sync_timestamp: u64, /// Sum of unspent note values (cached) pub shielded_balance: u64, + /// When notes were last synced (in-memory only, resets on app restart) + pub last_notes_synced_at: Option, + /// When nullifiers were last checked (in-memory only, resets on app restart) + pub last_nullifiers_synced_at: Option, } impl std::fmt::Debug for ShieldedWalletState { @@ -98,23 +112,16 @@ impl std::fmt::Debug for ShieldedWalletState { f.debug_struct("ShieldedWalletState") .field("notes_count", &self.notes.len()) .field("last_synced_index", &self.last_synced_index) + .field( + "last_nullifier_sync_height", + &self.last_nullifier_sync_height, + ) .field("shielded_balance", &self.shielded_balance) .finish_non_exhaustive() } } impl ShieldedWalletState { - /// Create a new shielded wallet state from ZIP32-derived keys. - pub fn new(keys: OrchardKeySet) -> Self { - Self { - keys, - notes: Vec::new(), - commitment_tree: ClientCommitmentTree::new(100), - last_synced_index: 0, - shielded_balance: 0, - } - } - /// Recalculate the cached shielded balance from unspent notes. pub fn recalculate_balance(&mut self) { self.shielded_balance = self diff --git a/src/ui/helpers.rs b/src/ui/helpers.rs index 77bded3af..2cd4b60b8 100644 --- a/src/ui/helpers.rs +++ b/src/ui/helpers.rs @@ -1098,3 +1098,21 @@ pub fn show_group_token_success_screen_with_fee( }); action } + +/// Check if a string looks like a Platform Bech32m address. +/// +/// Supports both the current prefix (`dash1`/`tdash1`) and the legacy +/// prefix (`evo1`/`tevo1`) so that old addresses stored in the DB or +/// copied from older tools continue to work. +pub fn is_platform_address(s: &str) -> bool { + s.starts_with("dash1") + || s.starts_with("tdash1") + || s.starts_with("evo1") + || s.starts_with("tevo1") +} + +/// Human-readable hint for Platform address input fields. +pub const PLATFORM_ADDRESS_HINT: &str = "dash1... or tdash1..."; + +/// Example Platform address prefixes for error messages. +pub const PLATFORM_ADDRESS_EXAMPLES: &str = "dash1.../tdash1..."; diff --git a/src/ui/identities/transfer_screen.rs b/src/ui/identities/transfer_screen.rs index 9d4f35ee9..ef8a23adc 100644 --- a/src/ui/identities/transfer_screen.rs +++ b/src/ui/identities/transfer_screen.rs @@ -261,8 +261,8 @@ impl TransferScreen { let input = self.platform_address_input.trim(); - // Try to parse as Bech32m Platform address first (evo1.../tevo1...) - if input.starts_with("evo1") || input.starts_with("tevo1") { + // Try to parse as Bech32m Platform address first (dash1.../tdash1...) + if crate::ui::helpers::is_platform_address(input) { let (addr, _network) = PlatformAddress::from_bech32m_string(input) .map_err(|e| format!("Invalid Bech32m address: {}", e))?; return Ok(addr); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 8b7e9494d..677f4c380 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -78,6 +78,7 @@ use tokens::update_token_config::UpdateTokenConfigScreen; use tools::transition_visualizer_screen::TransitionVisualizerScreen; use wallets::add_new_wallet_screen::AddNewWalletScreen; use wallets::shield_credits_screen::ShieldCreditsScreen; +use wallets::shield_from_asset_lock_screen::ShieldFromAssetLockScreen; use wallets::shielded_send_screen::ShieldedSendScreen; use wallets::unshield_credits_screen::UnshieldCreditsScreen; @@ -307,6 +308,7 @@ pub enum ScreenType { // Shielded screens ShieldCreditsScreen(WalletSeedHash), + ShieldFromAssetLockScreen(WalletSeedHash), ShieldedSendScreen(WalletSeedHash), UnshieldCreditsScreen(WalletSeedHash), @@ -427,6 +429,10 @@ impl PartialEq for ScreenType { (ScreenType::DashPayProfileSearch, ScreenType::DashPayProfileSearch) => true, // Shielded screens (ScreenType::ShieldCreditsScreen(_), ScreenType::ShieldCreditsScreen(_)) => true, + ( + ScreenType::ShieldFromAssetLockScreen(_), + ScreenType::ShieldFromAssetLockScreen(_), + ) => true, (ScreenType::ShieldedSendScreen(_), ScreenType::ShieldedSendScreen(_)) => true, (ScreenType::UnshieldCreditsScreen(_), ScreenType::UnshieldCreditsScreen(_)) => true, _ => false, @@ -688,6 +694,9 @@ impl ScreenType { ScreenType::ShieldCreditsScreen(seed_hash) => { Screen::ShieldCreditsScreen(ShieldCreditsScreen::new(*seed_hash, app_context)) } + ScreenType::ShieldFromAssetLockScreen(seed_hash) => Screen::ShieldFromAssetLockScreen( + ShieldFromAssetLockScreen::new(*seed_hash, app_context), + ), ScreenType::ShieldedSendScreen(seed_hash) => { Screen::ShieldedSendScreen(ShieldedSendScreen::new(*seed_hash, app_context)) } @@ -754,6 +763,7 @@ pub enum Screen { // Shielded Screens ShieldCreditsScreen(ShieldCreditsScreen), + ShieldFromAssetLockScreen(ShieldFromAssetLockScreen), ShieldedSendScreen(ShieldedSendScreen), UnshieldCreditsScreen(UnshieldCreditsScreen), @@ -852,6 +862,7 @@ impl Screen { Screen::DashPayProfileSearchScreen(screen) => screen.app_context = app_context, // Shielded screens Screen::ShieldCreditsScreen(screen) => screen.app_context = app_context.clone(), + Screen::ShieldFromAssetLockScreen(screen) => screen.app_context = app_context.clone(), Screen::ShieldedSendScreen(screen) => screen.app_context = app_context.clone(), Screen::UnshieldCreditsScreen(screen) => screen.app_context = app_context.clone(), } @@ -1054,6 +1065,9 @@ impl Screen { Screen::DashPayProfileSearchScreen(_) => ScreenType::DashPayProfileSearch, // Shielded screens Screen::ShieldCreditsScreen(s) => ScreenType::ShieldCreditsScreen(s.seed_hash), + Screen::ShieldFromAssetLockScreen(s) => { + ScreenType::ShieldFromAssetLockScreen(s.seed_hash) + } Screen::ShieldedSendScreen(s) => ScreenType::ShieldedSendScreen(s.seed_hash), Screen::UnshieldCreditsScreen(s) => ScreenType::UnshieldCreditsScreen(s.seed_hash), } @@ -1126,6 +1140,7 @@ impl ScreenLike for Screen { Screen::DashPayProfileSearchScreen(screen) => screen.refresh(), // Shielded screens Screen::ShieldCreditsScreen(_) => {} + Screen::ShieldFromAssetLockScreen(_) => {} Screen::ShieldedSendScreen(_) => {} Screen::UnshieldCreditsScreen(_) => {} } @@ -1196,6 +1211,7 @@ impl ScreenLike for Screen { Screen::DashPayProfileSearchScreen(screen) => screen.refresh_on_arrival(), // Shielded screens Screen::ShieldCreditsScreen(_) => {} + Screen::ShieldFromAssetLockScreen(_) => {} Screen::ShieldedSendScreen(_) => {} Screen::UnshieldCreditsScreen(_) => {} } @@ -1266,6 +1282,7 @@ impl ScreenLike for Screen { Screen::DashPayProfileSearchScreen(screen) => screen.ui(ctx), // Shielded screens Screen::ShieldCreditsScreen(screen) => screen.ui(ctx), + Screen::ShieldFromAssetLockScreen(screen) => screen.ui(ctx), Screen::ShieldedSendScreen(screen) => screen.ui(ctx), Screen::UnshieldCreditsScreen(screen) => screen.ui(ctx), } @@ -1370,6 +1387,9 @@ impl ScreenLike for Screen { } // Shielded screens Screen::ShieldCreditsScreen(screen) => screen.display_message(message, message_type), + Screen::ShieldFromAssetLockScreen(screen) => { + screen.display_message(message, message_type) + } Screen::ShieldedSendScreen(screen) => screen.display_message(message, message_type), Screen::UnshieldCreditsScreen(screen) => screen.display_message(message, message_type), } @@ -1546,6 +1566,9 @@ impl ScreenLike for Screen { Screen::ShieldCreditsScreen(screen) => { screen.display_task_result(backend_task_success_result) } + Screen::ShieldFromAssetLockScreen(screen) => { + screen.display_task_result(backend_task_success_result) + } Screen::ShieldedSendScreen(screen) => { screen.display_task_result(backend_task_success_result) } @@ -1620,6 +1643,7 @@ impl ScreenLike for Screen { Screen::DashPayProfileSearchScreen(_) => {} // Shielded screens Screen::ShieldCreditsScreen(_) => {} + Screen::ShieldFromAssetLockScreen(_) => {} Screen::ShieldedSendScreen(_) => {} Screen::UnshieldCreditsScreen(_) => {} } diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index 961b1cbfe..d8c1ea9f0 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -25,6 +25,28 @@ use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; +/// Reads the dashmate RPC password from `~/.dashmate/config.json`. +fn read_dashmate_rpc_password(config_name: &str) -> Result { + let home = directories::UserDirs::new() + .map(|dirs| dirs.home_dir().to_path_buf()) + .ok_or("Could not determine home directory")?; + let config_path = home.join(".dashmate").join("config.json"); + let contents = std::fs::read_to_string(&config_path) + .map_err(|e| format!("Failed to read {}: {e}", config_path.display()))?; + let json: serde_json::Value = serde_json::from_str(&contents) + .map_err(|e| format!("Failed to parse dashmate config: {e}"))?; + json.get("configs") + .and_then(|c| c.get(config_name)) + .and_then(|c| c.get("core")) + .and_then(|c| c.get("rpc")) + .and_then(|c| c.get("users")) + .and_then(|c| c.get("dashmate")) + .and_then(|c| c.get("password")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| format!("Password not found in dashmate config '{config_name}'")) +} + #[derive(Debug, Clone)] enum SpvClearMessage { Success(String), @@ -425,7 +447,22 @@ impl NetworkChooserScreen { ui.horizontal(|ui| { ui.text_edit_singleline(&mut self.local_network_dashmate_password); - if ui.button("Save").clicked() + let save_clicked = ui.button("Save").clicked(); + + let mut auto_update_succeeded = false; + if ui.button("Auto Update").clicked() { + match read_dashmate_rpc_password("local_seed") { + Ok(password) => { + self.local_network_dashmate_password = password; + auto_update_succeeded = true; + } + Err(e) => { + tracing::error!("Auto update failed: {e}"); + } + } + } + + if (save_clicked || auto_update_succeeded) && let Ok(mut config) = Config::load() && let Some(local_cfg) = config.config_for_network(Network::Regtest).clone() { diff --git a/src/ui/tools/address_balance_screen.rs b/src/ui/tools/address_balance_screen.rs index 1f8a75b7c..6c9d8f6cc 100644 --- a/src/ui/tools/address_balance_screen.rs +++ b/src/ui/tools/address_balance_screen.rs @@ -58,11 +58,11 @@ impl AddressBalanceScreen { ui.heading("Platform Address Balance Lookup"); ui.add_space(10.0); - ui.label("Enter a Platform address (evo1... or tevo1...):"); + ui.label("Enter a Platform address (dash1... or tdash1...):"); ui.add_space(5.0); let text_edit = TextEdit::singleline(&mut self.address_input) - .hint_text("evo1... or tevo1...") + .hint_text("dash1... or tdash1...") .desired_width(500.0); let response = ui.add(text_edit); diff --git a/src/ui/tools/platform_info_screen.rs b/src/ui/tools/platform_info_screen.rs index bf0b30e7f..282beedfe 100644 --- a/src/ui/tools/platform_info_screen.rs +++ b/src/ui/tools/platform_info_screen.rs @@ -89,6 +89,11 @@ impl PlatformInfoScreen { "Fetch Recently Completed Withdrawals", PlatformInfoTaskRequestType::RecentlyCompletedWithdrawals, ), + ( + "shielded_pool", + "Fetch Shielded Pool State", + PlatformInfoTaskRequestType::ShieldedPoolState, + ), ]; let mut action = AppAction::None; @@ -304,6 +309,7 @@ impl ScreenLike for PlatformInfoScreen { ("validator_set", "Current Validator Set Information"), ("withdrawals_queue", "Current Withdrawals in Queue"), ("recent_withdrawals", "Recently Completed Withdrawals"), + ("shielded_pool", "Shielded Pool State"), ]; // Try to identify which task completed based on active tasks diff --git a/src/ui/wallets/mod.rs b/src/ui/wallets/mod.rs index 9a344895f..613863181 100644 --- a/src/ui/wallets/mod.rs +++ b/src/ui/wallets/mod.rs @@ -5,6 +5,7 @@ pub mod create_asset_lock_screen; pub mod import_mnemonic_screen; pub mod send_screen; pub mod shield_credits_screen; +pub mod shield_from_asset_lock_screen; pub mod shielded_send_screen; pub mod shielded_tab; pub mod single_key_send_screen; diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index 622b62a2b..bdc2e09f4 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -267,6 +267,7 @@ fn allocate_platform_addresses( pub enum AddressType { Core, Platform, + Shielded, Unknown, } @@ -277,6 +278,8 @@ pub enum SourceSelection { CoreWallet, /// Use all Platform addresses (stores list of platform address, core address, and balance) PlatformAddresses(Vec<(PlatformAddress, Address, u64)>), + /// Use shielded pool balance (stores seed_hash and balance in credits) + Shielded(WalletSeedHash, u64), } /// Status of the send operation @@ -520,8 +523,13 @@ impl WalletSendScreen { return AddressType::Unknown; } + // Check for shielded address (dash1z... or tdash1z...) + if Self::is_shielded_address(trimmed) { + return AddressType::Shielded; + } + // Check for Platform address (Bech32m format) - if trimmed.starts_with("evo1") || trimmed.starts_with("tevo1") { + if crate::ui::helpers::is_platform_address(trimmed) { return AddressType::Platform; } @@ -533,6 +541,10 @@ impl WalletSendScreen { AddressType::Unknown } + fn is_shielded_address(s: &str) -> bool { + s.starts_with("dash1z") || s.starts_with("tdash1z") + } + fn min_output_amount( &self, input_type: AddressType, @@ -557,6 +569,10 @@ impl WalletSendScreen { (AddressType::Unknown, AddressType::Platform) => Some(platform_min), (AddressType::Core, AddressType::Unknown) => Some(core_min), (AddressType::Platform, AddressType::Unknown) => Some(platform_min), + (AddressType::Shielded, AddressType::Shielded) => Some(platform_min), + (AddressType::Shielded, AddressType::Platform) => Some(platform_min), + (AddressType::Shielded, _) => Some(platform_min), + (_, AddressType::Shielded) => Some(platform_min), } } @@ -619,6 +635,35 @@ impl WalletSendScreen { .collect() } + /// Get shielded pool balance for the selected wallet (if initialized). + fn get_shielded_balance(&self) -> Option<(WalletSeedHash, u64)> { + let seed_hash = self.selected_wallet_seed_hash?; + // Try in-memory state first (most accurate, reflects optimistic spend marks) + let states = self.app_context.shielded_states.lock().unwrap(); + if let Some(state) = states.get(&seed_hash) { + let balance = state.shielded_balance; + return if balance > 0 { + Some((seed_hash, balance)) + } else { + None + }; + } + drop(states); + // Fall back to database balance (works even if shielded state is temporarily + // removed during an async operation, or if the Shielded tab was never visited) + let network_str = self.app_context.network.to_string(); + let balance = self + .app_context + .db + .get_shielded_balance(&seed_hash, &network_str) + .ok()?; + if balance > 0 { + Some((seed_hash, balance)) + } else { + None + } + } + /// Get Core wallet balance fn get_core_balance(&self) -> u64 { self.selected_wallet @@ -653,6 +698,10 @@ impl WalletSendScreen { "Platform Transfer" } (Some(SourceSelection::PlatformAddresses(_)), AddressType::Core) => "Withdraw to Core", + (Some(SourceSelection::Shielded(..)), AddressType::Shielded) => { + "Private Transfer (Shielded)" + } + (Some(SourceSelection::Shielded(..)), AddressType::Platform) => "Unshield to Platform", _ => "Send", } } @@ -680,7 +729,7 @@ impl WalletSendScreen { let dest_type = Self::detect_address_type(&self.destination_address); if dest_type == AddressType::Unknown { return Err( - "Invalid destination address. Use a Dash address (X.../y...) or Platform address (evo1.../tevo1...)" + "Invalid destination address. Use a Dash address (X.../y...) or Platform address (dash1.../tdash1...)" .to_string(), ); } @@ -708,6 +757,12 @@ impl WalletSendScreen { (SourceSelection::PlatformAddresses(addresses), AddressType::Core) => { self.send_platform_to_core(seed_hash, addresses, network) } + (SourceSelection::Shielded(sh, _), AddressType::Shielded) => { + self.send_shielded_to_shielded(sh) + } + (SourceSelection::Shielded(sh, _), AddressType::Platform) => { + self.send_shielded_to_platform(sh) + } _ => Err("Invalid source/destination combination".to_string()), } } @@ -1249,6 +1304,65 @@ impl WalletSendScreen { } } + /// Send from shielded pool to another shielded address (private transfer). + fn send_shielded_to_shielded( + &mut self, + seed_hash: WalletSeedHash, + ) -> Result { + let amount_credits = self + .amount + .as_ref() + .ok_or_else(|| "Amount is required".to_string())? + .value(); + + let recipient = self.destination_address.trim().to_string(); + let recipient_bytes = if let Ok((addr, _)) = + dash_sdk::dpp::address_funds::OrchardAddress::from_bech32m_string(&recipient) + { + addr.to_raw_bytes().to_vec() + } else { + return Err("Invalid shielded address".to_string()); + }; + + self.send_status = SendStatus::WaitingForResult(Self::now_epoch_secs()); + Ok(AppAction::BackendTask( + crate::backend_task::BackendTask::ShieldedTask( + crate::backend_task::shielded::ShieldedTask::ShieldedTransfer { + seed_hash, + amount: amount_credits, + recipient_address_bytes: recipient_bytes, + }, + ), + )) + } + + /// Send from shielded pool to a platform address (unshield). + fn send_shielded_to_platform( + &mut self, + seed_hash: WalletSeedHash, + ) -> Result { + let amount_credits = self + .amount + .as_ref() + .ok_or_else(|| "Amount is required".to_string())? + .value(); + + let address_str = self.destination_address.trim(); + let (platform_addr, _) = PlatformAddress::from_bech32m_string(address_str) + .map_err(|e| format!("Invalid platform address: {e}"))?; + + self.send_status = SendStatus::WaitingForResult(Self::now_epoch_secs()); + Ok(AppAction::BackendTask( + crate::backend_task::BackendTask::ShieldedTask( + crate::backend_task::shielded::ShieldedTask::UnshieldCredits { + seed_hash, + amount: amount_credits, + to_platform_address: platform_addr, + }, + ), + )) + } + fn render_source_selection(&mut self, ui: &mut Ui) { let dark_mode = ui.ctx().style().visuals.dark_mode; @@ -1355,6 +1469,52 @@ impl WalletSendScreen { }); }); } + + // Shielded balance option + let shielded_balance = self.get_shielded_balance(); + if let Some((seed_hash, balance)) = shielded_balance + && balance > 0 + { + ui.add_space(5.0); + + let is_shielded_selected = + matches!(&self.selected_source, Some(SourceSelection::Shielded(..))); + + Frame::group(ui.style()) + .fill(if is_shielded_selected { + DashColors::DASH_BLUE.gamma_multiply(0.1) + } else { + DashColors::surface(dark_mode) + }) + .stroke(if is_shielded_selected { + egui::Stroke::new(2.0, DashColors::DASH_BLUE) + } else { + egui::Stroke::new(1.0, DashColors::border_light(dark_mode)) + }) + .inner_margin(Margin::symmetric(12, 8)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + let mut selected = is_shielded_selected; + if ui.radio_value(&mut selected, true, "").changed() && selected { + self.selected_source = + Some(SourceSelection::Shielded(seed_hash, balance)); + } + ui.label( + RichText::new("Shielded Balance") + .color(DashColors::text_primary(dark_mode)) + .strong(), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label( + RichText::new(Self::format_credits(balance)) + .color(DashColors::SUCCESS) + .strong(), + ); + }); + }); + }); + } } fn render_destination_input(&mut self, ui: &mut Ui) { @@ -1375,6 +1535,7 @@ impl WalletSendScreen { let (type_text, type_color) = match dest_type { AddressType::Core => ("Core Address", DashColors::DASH_BLUE), AddressType::Platform => ("Platform Address", Color32::from_rgb(130, 80, 220)), + AddressType::Shielded => ("Shielded Address", Color32::from_rgb(0, 180, 120)), AddressType::Unknown => ("", Color32::GRAY), }; ui.label( @@ -1394,7 +1555,7 @@ impl WalletSendScreen { .show(ui, |ui| { ui.add( egui::TextEdit::singleline(&mut self.destination_address) - .hint_text("Enter address (X.../y.../evo1.../tevo1...)") + .hint_text("Enter address (X.../y.../dash1.../tdash1...)") .desired_width(f32::INFINITY), ); }); @@ -1494,12 +1655,16 @@ impl WalletSendScreen { }; (Some(total.saturating_sub(max_fee)), Some(hint)) } + Some(SourceSelection::Shielded(_, balance)) => { + (Some(*balance), Some("Shielded pool balance".to_string())) + } None => (None, None), }; let input_type = match self.selected_source { Some(SourceSelection::CoreWallet) => AddressType::Core, Some(SourceSelection::PlatformAddresses(_)) => AddressType::Platform, + Some(SourceSelection::Shielded(_, _)) => AddressType::Shielded, None => AddressType::Unknown, }; let output_type = Self::detect_address_type(&self.destination_address); @@ -2173,7 +2338,7 @@ impl WalletSendScreen { ui.label("To:"); ui.add( egui::TextEdit::singleline(&mut self.advanced_outputs[idx].address) - .hint_text("Enter address (X.../y.../evo1.../tevo1...)") + .hint_text("Enter address (X.../y.../dash1.../tdash1...)") .desired_width(350.0), ); @@ -2184,6 +2349,9 @@ impl WalletSendScreen { AddressType::Platform => { ("Platform", Color32::from_rgb(130, 80, 220)) } + AddressType::Shielded => { + ("Shielded", Color32::from_rgb(0, 180, 120)) + } AddressType::Unknown => ("", Color32::GRAY), }; ui.label( @@ -2732,6 +2900,24 @@ impl ScreenLike for WalletSendScreen { self.send_status = SendStatus::Complete("Platform credits transferred successfully!".to_string()); } + crate::backend_task::BackendTaskSuccessResult::ShieldedTransferComplete { + amount, + .. + } => { + self.send_status = SendStatus::Complete(format!( + "Shielded transfer of {} complete!", + format_credits_as_dash(amount) + )); + } + crate::backend_task::BackendTaskSuccessResult::ShieldedCreditsUnshielded { + amount, + .. + } => { + self.send_status = SendStatus::Complete(format!( + "Unshielded {} to platform address!", + format_credits_as_dash(amount) + )); + } _ => { // Ignore other results } diff --git a/src/ui/wallets/shield_credits_screen.rs b/src/ui/wallets/shield_credits_screen.rs index 94a397485..a8149a582 100644 --- a/src/ui/wallets/shield_credits_screen.rs +++ b/src/ui/wallets/shield_credits_screen.rs @@ -1,5 +1,6 @@ use crate::app::AppAction; use crate::backend_task::shielded::ShieldedTask; +use crate::backend_task::shielded::bundle::ShieldStage; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; use crate::model::wallet::WalletSeedHash; @@ -9,14 +10,18 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::{MessageType, RootScreenType, ScreenLike}; use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; +use dash_sdk::dpp::serialization::PlatformSerializable; +use dash_sdk::dpp::state_transition::proof_result::StateTransitionProofResult; use eframe::egui::{self, Context}; use egui::{Color32, RichText}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; +use std::time::Duration; #[derive(PartialEq)] enum Status { NotStarted, WaitingForResult, + BatchInProgress, Complete, } @@ -28,6 +33,19 @@ pub struct ShieldCreditsScreen { status: Status, error_message: Option, success_message: Option, + // Batch mode (dev only) + repeat_count_str: String, + parallel: bool, + batch_total: u32, + batch_succeeded: u32, + batch_failed: u32, + batch_remaining: u32, + /// Queued task to dispatch on next frame (for sequential batch mode). + pending_next_task: Option, + /// Per-operation progress for parallel batch mode. + batch_stages: Option>>>, + /// JSON of a failed state transition to show in the popup. + json_preview: Option, } impl ShieldCreditsScreen { @@ -53,6 +71,15 @@ impl ShieldCreditsScreen { status: Status::NotStarted, error_message: None, success_message: None, + repeat_count_str: "1".to_string(), + parallel: false, + batch_total: 0, + batch_succeeded: 0, + batch_failed: 0, + batch_remaining: 0, + pending_next_task: None, + batch_stages: None, + json_preview: None, } } @@ -61,21 +88,255 @@ impl ShieldCreditsScreen { if trimmed.is_empty() { return None; } - // Try parsing as DASH amount first (has decimal point) - if trimmed.contains('.') { - let dash: f64 = trimmed.parse().ok()?; - if dash <= 0.0 { - return None; + let dash: f64 = trimmed.parse().ok()?; + if dash <= 0.0 { + return None; + } + Some((dash * CREDITS_PER_DUFF as f64 * 1e8) as u64) + } + + fn parse_repeat_count(&self) -> u32 { + self.repeat_count_str + .trim() + .parse::() + .unwrap_or(1) + .clamp(1, 1000) + } + + /// Read the current nonce for our from_address from the wallet. + fn read_base_nonce(&self) -> Option { + let from_address = self.from_address?; + let wallets = self.app_context.wallets.read().unwrap(); + let wallet_arc = wallets.get(&self.seed_hash)?; + let wallet = wallet_arc.read().unwrap(); + wallet + .platform_address_info + .iter() + .find_map(|(addr, info)| { + let platform_addr = PlatformAddress::try_from(addr.clone()).ok()?; + if platform_addr == from_address { + Some(info.nonce) + } else { + None + } + }) + } + + /// Read the current balance (in credits) for our from_address from the wallet. + fn read_address_balance(&self) -> Option { + let from_address = self.from_address?; + let wallets = self.app_context.wallets.read().unwrap(); + let wallet_arc = wallets.get(&self.seed_hash)?; + let wallet = wallet_arc.read().unwrap(); + wallet + .platform_address_info + .iter() + .find_map(|(addr, info)| { + let platform_addr = PlatformAddress::try_from(addr.clone()).ok()?; + if platform_addr == from_address { + Some(info.balance) + } else { + None + } + }) + } + + /// Build a single ShieldCredits task with optional nonce override. + fn make_shield_task( + &self, + amount: u64, + addr: PlatformAddress, + nonce_override: Option, + ) -> BackendTask { + BackendTask::ShieldedTask(ShieldedTask::ShieldCredits { + seed_hash: self.seed_hash, + amount, + from_address: addr, + nonce_override, + }) + } + + /// Queue the next sequential batch task if any remain. + fn queue_next_sequential(&mut self) { + if self.batch_remaining > 0 + && let (Some(amount), Some(addr)) = (self.parse_amount_credits(), self.from_address) + { + self.batch_remaining -= 1; + self.pending_next_task = Some(self.make_shield_task(amount, addr, None)); + } + } + + /// Check if the sequential batch is complete and update status accordingly. + fn check_batch_complete(&mut self) { + // Only for sequential mode (parallel mode detects completion from shared state) + if self.batch_stages.is_none() + && self.batch_succeeded + self.batch_failed >= self.batch_total + { + self.status = Status::Complete; + self.success_message = Some(format!( + "Batch complete: {} succeeded, {} failed out of {}", + self.batch_succeeded, self.batch_failed, self.batch_total, + )); + } + } + + /// Spawn parallel batch: build proofs in parallel, broadcast in nonce order. + fn spawn_parallel_batch(&mut self, amount: u64, addr: PlatformAddress, repeat: u32) { + let base_nonce = match self.read_base_nonce() { + Some(n) => n, + None => { + self.error_message = Some("Could not read nonce from wallet".to_string()); + return; } - Some((dash * CREDITS_PER_DUFF as f64 * 1e8) as u64) - } else { - // Parse as raw credits - let credits: u64 = trimmed.parse().ok()?; - if credits == 0 { - return None; + }; + let default_address = match self.app_context.shielded_default_address(&self.seed_hash) { + Some(a) => a, + None => { + self.error_message = Some("Shielded wallet not initialized".to_string()); + return; } - Some(credits) - } + }; + + self.batch_total = repeat; + self.batch_succeeded = 0; + self.batch_failed = 0; + self.batch_remaining = 0; + self.status = Status::BatchInProgress; + + let stages: Vec>> = (0..repeat) + .map(|_| Arc::new(Mutex::new(ShieldStage::Queued))) + .collect(); + self.batch_stages = Some(stages.clone()); + + let app_ctx = self.app_context.clone(); + let seed_hash = self.seed_hash; + + // Single coordinating task: build in parallel, broadcast in order + tokio::spawn(async move { + use crate::backend_task::shielded::bundle; + use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; + + // Phase 1: Build all proofs in parallel + let build_futures: Vec<_> = (0..repeat) + .map(|i| { + let app_ctx = app_ctx.clone(); + let stage = stages[i as usize].clone(); + let nonce = base_nonce + 1 + i; + + async move { + *stage.lock().unwrap() = ShieldStage::BuildingProof { nonce }; + + // build_shield_credit is sync (CPU-bound proof generation) + let result = tokio::task::spawn_blocking(move || { + bundle::build_shield_credit( + &app_ctx, + &seed_hash, + &default_address, + amount, + addr, + nonce, + ) + }) + .await + .map_err(|e| format!("Build task panicked: {e}")) + .and_then(|r| r); + + match &result { + Ok(_) => { + *stage.lock().unwrap() = ShieldStage::WaitingToBroadcast; + } + Err(e) => { + *stage.lock().unwrap() = ShieldStage::Failed { + error: e.clone(), + st_json: None, + }; + } + } + + result + } + }) + .collect(); + + let build_results = futures::future::join_all(build_futures).await; + + // Phase 2: Broadcast sequentially in nonce order. + // We use broadcast_and_wait so each nonce is committed on-chain before + // the next is submitted (the platform increments the address nonce only + // after a state transition is finalised, so broadcasting without waiting + // would cause "expected N, got N+1" errors). + let sdk = { + let guard = app_ctx.sdk.read().unwrap(); + guard.clone() + }; + + for (i, result) in build_results.into_iter().enumerate() { + let stage = &stages[i]; + match result { + Ok(state_transition) => { + *stage.lock().unwrap() = ShieldStage::Broadcasting; + + // Serialize first (while we still own the value) so we can + // show the bytes in the error popup if broadcast fails. + let st_repr: Option = + serde_json::to_string_pretty(&state_transition) + .ok() + .or_else(|| { + state_transition.serialize_to_bytes().map(hex::encode).ok() + }); + + match state_transition.broadcast(&sdk, None).await { + Ok(_) => { + // Wait for the state transition to be confirmed on-chain so + // the address nonce increments before we send the next nonce. + // If proof-response parsing fails (network version mismatch), + // fall back to a fixed delay — the broadcast still went through. + let wait_ok = state_transition + .wait_for_response::(&sdk, None) + .await + .is_ok(); + if !wait_ok { + tokio::time::sleep(Duration::from_secs(3)).await; + } + // Update stored nonce so the next batch reads the correct value + app_ctx.bump_platform_address_nonce(&seed_hash, &addr); + *stage.lock().unwrap() = ShieldStage::Complete; + } + Err(e) => { + *stage.lock().unwrap() = ShieldStage::Failed { + error: format!("Broadcast failed: {e}"), + st_json: st_repr, + }; + // Nonces are sequential — all remaining will fail too + for remaining in stages.iter().skip(i + 1) { + let mut s = remaining.lock().unwrap(); + if !s.is_terminal() { + *s = ShieldStage::Failed { + error: "Skipped: earlier nonce failed".to_string(), + st_json: None, + }; + } + } + break; + } + } + } + Err(_) => { + // Build failed — can't broadcast this or any subsequent nonce + for remaining in stages.iter().skip(i + 1) { + let mut s = remaining.lock().unwrap(); + if !s.is_terminal() { + *s = ShieldStage::Failed { + error: "Skipped: earlier nonce failed".to_string(), + st_json: None, + }; + } + } + break; + } + } + } + }); } } @@ -97,10 +358,15 @@ impl ScreenLike for ShieldCreditsScreen { RootScreenType::RootScreenWalletsBalances, ); + // Dispatch pending sequential task from previous frame + if let Some(task) = self.pending_next_task.take() { + action = AppAction::BackendTask(task); + } + island_central_panel(ctx, |ui| { ui.heading("Shield Credits"); ui.add_space(10.0); - ui.label("Move credits from your platform identity into the shielded pool."); + ui.label("Move credits from a platform address into the shielded pool."); ui.add_space(15.0); // Error/success messages @@ -122,6 +388,13 @@ impl ScreenLike for ShieldCreditsScreen { ui.horizontal(|ui| { ui.label("From platform address:"); ui.monospace(format!("{}", addr)); + if let Some(nonce) = self.read_base_nonce() { + ui.label( + RichText::new(format!("(nonce: {})", nonce)) + .color(Color32::GRAY) + .small(), + ); + } }); ui.add_space(10.0); } else { @@ -132,30 +405,198 @@ impl ScreenLike for ShieldCreditsScreen { return; } + // Balance display + if let Some(balance_credits) = self.read_address_balance() { + let balance_dash = balance_credits as f64 / CREDITS_PER_DUFF as f64 / 1e8; + ui.label( + RichText::new(format!("Available: {:.8} DASH", balance_dash)) + .color(Color32::from_rgb(100, 180, 100)), + ); + ui.add_space(5.0); + } + // Amount input ui.horizontal(|ui| { - ui.label("Amount (DASH or credits):"); + ui.label("Amount (DASH):"); ui.text_edit_singleline(&mut self.amount_str); }); ui.add_space(5.0); - if let Some(credits) = self.parse_amount_credits() { - let dash = credits as f64 / CREDITS_PER_DUFF as f64 / 1e8; - ui.label(format!("= {:.8} DASH ({} credits)", dash, credits)); + // Dev-mode batch controls + if self.app_context.is_developer_mode() && self.status == Status::NotStarted { + ui.add_space(10.0); + ui.horizontal(|ui| { + ui.label("Repeat"); + let te = + egui::TextEdit::singleline(&mut self.repeat_count_str).desired_width(50.0); + ui.add(te); + ui.label("times"); + }); + ui.checkbox(&mut self.parallel, "Parallel"); } ui.add_space(15.0); - // Confirm button - let can_confirm = - self.status == Status::NotStarted && self.parse_amount_credits().is_some(); + // Progress display + let is_busy = + self.status == Status::WaitingForResult || self.status == Status::BatchInProgress; + + if self.status == Status::BatchInProgress { + // Clone Arc refs cheaply so we can mutate `self` inside the loop + let stages_snapshot = self.batch_stages.clone(); + if let Some(stages) = stages_snapshot { + // Parallel mode: per-operation progress bars + let all_done = stages.iter().all(|s| s.lock().unwrap().is_terminal()); + + if !all_done { + ctx.request_repaint_after(Duration::from_millis(100)); + } + + // Summary line + let succeeded = stages + .iter() + .filter(|s| matches!(*s.lock().unwrap(), ShieldStage::Complete)) + .count(); + let failed = stages + .iter() + .filter(|s| matches!(*s.lock().unwrap(), ShieldStage::Failed { .. })) + .count(); + + if all_done { + if failed > 0 { + ui.colored_label( + Color32::from_rgb(255, 100, 100), + format!( + "Batch complete: {} succeeded, {} failed out of {}", + succeeded, + failed, + stages.len(), + ), + ); + } else { + ui.colored_label( + Color32::from_rgb(50, 180, 50), + format!("Batch complete: all {} succeeded", stages.len()), + ); + } + } else { + ui.label(format!( + "Succeeded {}/{} Failed {}/{}", + succeeded, + stages.len(), + failed, + stages.len(), + )); + } + ui.add_space(5.0); + + // Collect (stage, st_json) to avoid borrow conflicts inside closure + let rows: Vec<(ShieldStage, Option)> = stages + .iter() + .map(|s| { + let s = s.lock().unwrap().clone(); + let json = if let ShieldStage::Failed { ref st_json, .. } = s { + st_json.clone() + } else { + None + }; + (s, json) + }) + .collect(); + + let total = rows.len(); + let mut pending_json: Option = None; + + egui::ScrollArea::vertical() + .max_height(400.0) + .show(ui, |ui| { + for (i, (stage, st_json)) in rows.iter().enumerate() { + let fraction = stage.progress_fraction(); + let text = format!("[{}/{}] {}", i + 1, total, stage.label()); + + let color = match stage { + ShieldStage::Queued => Color32::GRAY, + ShieldStage::BuildingProof { .. } => { + crate::ui::theme::DashColors::DASH_BLUE + } + ShieldStage::WaitingToBroadcast => { + Color32::from_rgb(100, 180, 255) + } + ShieldStage::Broadcasting => Color32::from_rgb(255, 165, 0), + ShieldStage::Complete => Color32::from_rgb(50, 180, 50), + ShieldStage::Failed { .. } => Color32::from_rgb(220, 60, 60), + }; + + if let Some(json_str) = st_json { + // Failed bar with viewable state transition: bar + button + ui.horizontal(|ui| { + let btn_width = 100.0_f32; + let bar_width = + (ui.available_width() - btn_width - 6.0).max(100.0); + ui.add_sized( + [bar_width, 20.0], + egui::ProgressBar::new(fraction).text(text).fill(color), + ); + let btn = egui::Button::new( + RichText::new("View JSON") + .color(Color32::WHITE) + .size(12.0), + ) + .fill(Color32::from_rgb(80, 80, 80)); + if ui + .add_sized([btn_width, 20.0], btn) + .on_hover_text("View state transition JSON") + .clicked() + { + pending_json = Some(json_str.clone()); + } + }); + } else { + let bar = + egui::ProgressBar::new(fraction).text(text).fill(color); + if matches!(stage, ShieldStage::BuildingProof { .. }) { + ui.add(bar.animate(true)); + } else { + ui.add(bar); + } + } + } + }); - if self.status == Status::WaitingForResult { + if let Some(json) = pending_json { + self.json_preview = Some(json); + } + + if all_done { + ui.add_space(10.0); + if ui.button("Done").clicked() { + action = AppAction::PopScreen; + } + } + } else { + // Sequential mode: simple counter + ui.horizontal(|ui| { + ui.add(egui::Spinner::new()); + ui.label(format!( + "Succeeded {}/{} Failed {}/{}", + self.batch_succeeded, + self.batch_total, + self.batch_failed, + self.batch_total, + )); + }); + } + } else if self.status == Status::WaitingForResult { ui.horizontal(|ui| { ui.add(egui::Spinner::new()); ui.label("Shielding credits..."); }); - } else { + } + + // Buttons (only when not busy) + if !is_busy && self.status == Status::NotStarted { + let can_confirm = self.parse_amount_credits().is_some(); + ui.horizontal(|ui| { if ui .add_enabled( @@ -169,15 +610,48 @@ impl ScreenLike for ShieldCreditsScreen { && let (Some(amount), Some(addr)) = (self.parse_amount_credits(), self.from_address) { - self.status = Status::WaitingForResult; self.error_message = None; - action = AppAction::BackendTask(BackendTask::ShieldedTask( - ShieldedTask::ShieldCredits { - seed_hash: self.seed_hash, - amount, - from_address: addr, - }, - )); + let repeat = if self.app_context.is_developer_mode() { + self.parse_repeat_count() + } else { + 1 + }; + + // Balance check: total cost must not exceed available balance + if let Some(balance) = self.read_address_balance() { + let total = amount.saturating_mul(repeat as u64); + if total > balance { + let total_dash = total as f64 / CREDITS_PER_DUFF as f64 / 1e8; + let balance_dash = + balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; + self.error_message = Some(format!( + "Insufficient balance: {repeat}x {:.8} DASH = {:.8} DASH total, but only {:.8} DASH available", + amount as f64 / CREDITS_PER_DUFF as f64 / 1e8, + total_dash, + balance_dash, + )); + return; + } + } + + if repeat <= 1 { + // Single operation + self.status = Status::WaitingForResult; + action = + AppAction::BackendTask(self.make_shield_task(amount, addr, None)); + } else if self.parallel { + // Parallel batch: spawn directly with progress tracking + self.spawn_parallel_batch(amount, addr, repeat); + } else { + // Sequential batch: fire first, queue rest + self.batch_total = repeat; + self.batch_succeeded = 0; + self.batch_failed = 0; + self.batch_remaining = repeat - 1; + self.status = Status::BatchInProgress; + action = + AppAction::BackendTask(self.make_shield_task(amount, addr, None)); + } } ui.add_space(10.0); @@ -188,6 +662,31 @@ impl ScreenLike for ShieldCreditsScreen { } }); + // JSON preview popup + if let Some(json) = self.json_preview.clone() { + let mut is_open = true; + egui::Window::new("State Transition JSON") + .collapsible(false) + .resizable(true) + .max_height(500.0) + .max_width(800.0) + .scroll(true) + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) + .open(&mut is_open) + .show(ctx, |ui| { + egui::ScrollArea::vertical().show(ui, |ui| { + ui.monospace(&json); + }); + ui.add_space(10.0); + if ui.button("Copy").clicked() { + let _ = crate::ui::helpers::copy_text_to_clipboard(&json); + } + }); + if !is_open { + self.json_preview = None; + } + } + action } @@ -196,9 +695,18 @@ impl ScreenLike for ShieldCreditsScreen { BackendTaskSuccessResult::ShieldedCreditsShielded { seed_hash, amount } if seed_hash == self.seed_hash => { - self.status = Status::Complete; - let dash = amount as f64 / CREDITS_PER_DUFF as f64 / 1e8; - self.success_message = Some(format!("Successfully shielded {:.8} DASH", dash)); + if self.status == Status::BatchInProgress { + // Sequential batch mode + self.batch_succeeded += 1; + self.check_batch_complete(); + if self.status == Status::BatchInProgress { + self.queue_next_sequential(); + } + } else { + self.status = Status::Complete; + let dash = amount as f64 / CREDITS_PER_DUFF as f64 / 1e8; + self.success_message = Some(format!("Successfully shielded {:.8} DASH", dash)); + } } _ => {} } @@ -207,8 +715,17 @@ impl ScreenLike for ShieldCreditsScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { match message_type { MessageType::Error => { - self.status = Status::NotStarted; - self.error_message = Some(message.to_string()); + if self.status == Status::BatchInProgress { + // Sequential batch mode + self.batch_failed += 1; + self.check_batch_complete(); + if self.status == Status::BatchInProgress { + self.queue_next_sequential(); + } + } else { + self.status = Status::NotStarted; + self.error_message = Some(message.to_string()); + } } _ => { self.success_message = Some(message.to_string()); diff --git a/src/ui/wallets/shield_from_asset_lock_screen.rs b/src/ui/wallets/shield_from_asset_lock_screen.rs new file mode 100644 index 000000000..36e275ae0 --- /dev/null +++ b/src/ui/wallets/shield_from_asset_lock_screen.rs @@ -0,0 +1,214 @@ +use crate::app::AppAction; +use crate::backend_task::shielded::ShieldedTask; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; +use crate::context::AppContext; +use crate::model::wallet::WalletSeedHash; +use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::styled::island_central_panel; +use crate::ui::components::top_panel::add_top_panel; +use crate::ui::{MessageType, RootScreenType, ScreenLike}; +use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; +use eframe::egui::{self, Context}; +use egui::{Color32, RichText}; +use std::sync::Arc; + +#[derive(PartialEq)] +enum Status { + NotStarted, + WaitingForResult, + Complete, +} + +pub struct ShieldFromAssetLockScreen { + pub app_context: Arc, + pub seed_hash: WalletSeedHash, + amount_str: String, + core_balance_duffs: u64, + status: Status, + error_message: Option, + success_message: Option, +} + +impl ShieldFromAssetLockScreen { + pub fn new(seed_hash: WalletSeedHash, app_context: &Arc) -> Self { + let core_balance_duffs = { + let wallets = app_context.wallets.read().unwrap(); + wallets + .get(&seed_hash) + .map(|w| { + let wallet = w.read().unwrap(); + wallet.total_balance_duffs() + }) + .unwrap_or(0) + }; + + Self { + app_context: app_context.clone(), + seed_hash, + amount_str: String::new(), + core_balance_duffs, + status: Status::NotStarted, + error_message: None, + success_message: None, + } + } + + /// Parse amount input as DASH (decimal) and return duffs. + fn parse_amount_duffs(&self) -> Option { + let trimmed = self.amount_str.trim(); + if trimmed.is_empty() { + return None; + } + let dash: f64 = trimmed.parse().ok()?; + if dash <= 0.0 { + return None; + } + let duffs = (dash * 1e8) as u64; + if duffs == 0 { + return None; + } + Some(duffs) + } +} + +impl ScreenLike for ShieldFromAssetLockScreen { + fn ui(&mut self, ctx: &Context) -> AppAction { + let mut action = add_top_panel( + ctx, + &self.app_context, + vec![ + ("Wallets", AppAction::PopScreen), + ("Shield from Core", AppAction::None), + ], + vec![], + ); + + action |= add_left_panel( + ctx, + &self.app_context, + RootScreenType::RootScreenWalletsBalances, + ); + + island_central_panel(ctx, |ui| { + ui.heading("Shield from Core Wallet"); + ui.add_space(10.0); + ui.label("Send core DASH directly into the shielded pool via an asset lock."); + ui.add_space(5.0); + + let dash_balance = self.core_balance_duffs as f64 / 1e8; + ui.label(format!( + "Available core wallet balance: {:.8} DASH", + dash_balance + )); + ui.add_space(15.0); + + // Error/success messages + if let Some(err) = &self.error_message { + ui.colored_label(Color32::from_rgb(255, 100, 100), err); + ui.add_space(5.0); + } + if let Some(msg) = &self.success_message { + ui.colored_label(Color32::DARK_GREEN, msg); + ui.add_space(10.0); + if ui.button("Done").clicked() { + action = AppAction::PopScreen; + } + return; + } + + // Amount input + ui.horizontal(|ui| { + ui.label("Amount (DASH):"); + ui.text_edit_singleline(&mut self.amount_str); + }); + if let Some(duffs) = self.parse_amount_duffs() { + let credits = duffs * CREDITS_PER_DUFF; + let dash = duffs as f64 / 1e8; + ui.label(format!( + "= {:.8} DASH = {} credits on platform", + dash, credits + )); + if duffs > self.core_balance_duffs { + ui.colored_label( + Color32::from_rgb(255, 100, 100), + "Exceeds core wallet balance", + ); + } + } + ui.add_space(15.0); + + // Confirm + let amount_ok = self + .parse_amount_duffs() + .is_some_and(|a| a <= self.core_balance_duffs); + let can_confirm = self.status == Status::NotStarted && amount_ok; + + if self.status == Status::WaitingForResult { + ui.horizontal(|ui| { + ui.add(egui::Spinner::new()); + ui.label("Creating asset lock and shielding... (this may take a few minutes)"); + }); + } else { + ui.horizontal(|ui| { + if ui + .add_enabled( + can_confirm, + egui::Button::new( + RichText::new("Shield from Core") + .color(Color32::WHITE) + .size(16.0), + ) + .fill(crate::ui::theme::DashColors::DASH_BLUE), + ) + .clicked() + && let Some(amount_duffs) = self.parse_amount_duffs() + { + self.status = Status::WaitingForResult; + self.error_message = None; + action = AppAction::BackendTask(BackendTask::ShieldedTask( + ShieldedTask::ShieldFromAssetLock { + seed_hash: self.seed_hash, + amount_duffs, + }, + )); + } + + ui.add_space(10.0); + if ui.button("Cancel").clicked() { + action = AppAction::PopScreen; + } + }); + } + }); + + action + } + + fn display_task_result(&mut self, result: BackendTaskSuccessResult) { + match result { + BackendTaskSuccessResult::ShieldedFromAssetLock { seed_hash, amount } + if seed_hash == self.seed_hash => + { + self.status = Status::Complete; + let dash = amount as f64 / CREDITS_PER_DUFF as f64 / 1e8; + self.success_message = Some(format!( + "Successfully shielded {:.8} DASH from core wallet", + dash + )); + } + _ => {} + } + } + + fn display_message(&mut self, message: &str, message_type: MessageType) { + match message_type { + MessageType::Error => { + self.status = Status::NotStarted; + self.error_message = Some(message.to_string()); + } + _ => { + self.success_message = Some(message.to_string()); + } + } + } +} diff --git a/src/ui/wallets/shielded_send_screen.rs b/src/ui/wallets/shielded_send_screen.rs index d00dfff40..ae8c26006 100644 --- a/src/ui/wallets/shielded_send_screen.rs +++ b/src/ui/wallets/shielded_send_screen.rs @@ -23,7 +23,7 @@ pub struct ShieldedSendScreen { pub app_context: Arc, pub seed_hash: WalletSeedHash, amount_str: String, - recipient_address_hex: String, + recipient_address_input: String, max_balance: u64, status: Status, error_message: Option, @@ -44,7 +44,7 @@ impl ShieldedSendScreen { app_context: app_context.clone(), seed_hash, amount_str: String::new(), - recipient_address_hex: String::new(), + recipient_address_input: String::new(), max_balance, status: Status::NotStarted, error_message: None, @@ -73,10 +73,17 @@ impl ShieldedSendScreen { } fn validate_recipient(&self) -> Option> { - let trimmed = self.recipient_address_hex.trim(); + let trimmed = self.recipient_address_input.trim(); if trimmed.is_empty() { return None; } + // Try bech32m first (dash1z... or tdash1z...) + if let Ok((addr, _network)) = + dash_sdk::dpp::address_funds::OrchardAddress::from_bech32m_string(trimmed) + { + return Some(addr.to_raw_bytes().to_vec()); + } + // Fall back to raw hex (43 bytes = 86 hex chars) let bytes = hex::decode(trimmed).ok()?; if bytes.len() != 43 { return None; @@ -131,15 +138,13 @@ impl ScreenLike for ShieldedSendScreen { } // Recipient address input - ui.label("Recipient shielded address (hex, 43 bytes):"); + ui.label("Recipient shielded address (dash1z.../tdash1z... or hex):"); ui.add_space(2.0); - ui.text_edit_singleline(&mut self.recipient_address_hex); - if !self.recipient_address_hex.trim().is_empty() && self.validate_recipient().is_none() + ui.text_edit_singleline(&mut self.recipient_address_input); + if !self.recipient_address_input.trim().is_empty() + && self.validate_recipient().is_none() { - ui.colored_label( - Color32::from_rgb(255, 100, 100), - "Invalid address (expected 86 hex chars = 43 bytes)", - ); + ui.colored_label(Color32::from_rgb(255, 100, 100), "Invalid shielded address"); } ui.add_space(10.0); diff --git a/src/ui/wallets/shielded_tab.rs b/src/ui/wallets/shielded_tab.rs index f842c29de..5775a03a7 100644 --- a/src/ui/wallets/shielded_tab.rs +++ b/src/ui/wallets/shielded_tab.rs @@ -30,6 +30,10 @@ pub struct ShieldedTabView { pending_task: Option, /// Wallet unlock popup for the initialize flow. wallet_unlock_popup: WalletUnlockPopup, + /// Currently selected diversified address index. + selected_address_index: u32, + /// Number of diversified addresses generated (always >= 1). + address_count: u32, } impl ShieldedTabView { @@ -46,9 +50,15 @@ impl ShieldedTabView { tree_synced: false, pending_task: None, wallet_unlock_popup: WalletUnlockPopup::new(), + selected_address_index: 0, + address_count: 1, } } + pub fn is_syncing(&self) -> bool { + self.syncing + } + pub fn update_seed_hash(&mut self, seed_hash: WalletSeedHash) { if self.seed_hash != seed_hash { self.seed_hash = seed_hash; @@ -89,14 +99,16 @@ impl ShieldedTabView { new_notes, balance, } if *seed_hash == self.seed_hash => { - self.syncing = false; self.tree_synced = true; self.shielded_balance = *balance; if *new_notes > 0 { self.success_message = Some(format!("Synced {} new note(s)", new_notes)); - } else { - self.success_message = Some("Notes are up to date".to_string()); } + // Auto-check nullifiers after sync to detect spent notes + self.pending_task = + Some(BackendTask::ShieldedTask(ShieldedTask::CheckNullifiers { + seed_hash: self.seed_hash, + })); true } BackendTaskSuccessResult::ShieldedCreditsShielded { seed_hash, amount } @@ -119,10 +131,26 @@ impl ShieldedTabView { self.success_message = Some(format!("Unshielded {}", format_credits(*amount))); true } + BackendTaskSuccessResult::ShieldedFromAssetLock { seed_hash, amount } + if *seed_hash == self.seed_hash => + { + self.success_message = Some(format!( + "Shielded {} from core wallet", + format_credits(*amount) + )); + true + } BackendTaskSuccessResult::ShieldedNullifiersChecked { seed_hash, spent_count, } if *seed_hash == self.seed_hash => { + self.syncing = false; + // Update balance from state after nullifier check + let states = self.app_context.shielded_states.lock().unwrap(); + if let Some(state) = states.get(&self.seed_hash) { + self.shielded_balance = state.shielded_balance; + } + drop(states); if *spent_count > 0 { self.success_message = Some(format!("Detected {} spent note(s)", spent_count)); } @@ -180,7 +208,26 @@ impl ShieldedTabView { ui.add_space(5.0); } - // --- Not yet initialized: show Initialize button + unlock popup --- + // --- Not yet initialized --- + // Auto-initialize if the wallet is already open (no user click needed) + if !self.is_initialized && !self.initializing { + let wallet_arc = { + let wallets = self.app_context.wallets.read().unwrap(); + wallets.get(&self.seed_hash).cloned() + }; + if let Some(wallet) = &wallet_arc + && !wallet_needs_unlock(wallet) + { + let _ = try_open_wallet_no_password(wallet); + self.initializing = true; + action |= AppAction::BackendTask(BackendTask::ShieldedTask( + ShieldedTask::InitializeShieldedWallet { + seed_hash: self.seed_hash, + }, + )); + } + } + if !self.is_initialized { if self.initializing { ui.horizontal(|ui| { @@ -291,12 +338,19 @@ impl ShieldedTabView { ui.add_space(10.0); - // Payment address + // Payment address (bech32m encoded: dash1z... or tdash1z...) let address_str = { let states = self.app_context.shielded_states.lock().unwrap(); states.get(&self.seed_hash).map(|state| { - let raw = state.keys.default_address.to_raw_address_bytes(); - hex::encode(raw) + use dash_sdk::dpp::address_funds::OrchardAddress; + use dash_sdk::grovedb_commitment_tree::Scope; + let addr = state + .keys + .fvk + .address_at(self.selected_address_index, Scope::External); + let raw = addr.to_raw_address_bytes(); + let orchard_addr = OrchardAddress::from_raw_bytes(&raw); + orchard_addr.to_bech32m_string(self.app_context.network) }) }; @@ -306,19 +360,39 @@ impl ShieldedTabView { .inner_margin(Margin::symmetric(16, 12)) .corner_radius(8.0) .show(ui, |ui| { - ui.label( - RichText::new("Shielded Payment Address") + ui.horizontal(|ui| { + ui.label( + RichText::new(format!( + "Shielded Payment Address ({})", + self.selected_address_index + )) .size(14.0) .color(DashColors::text_secondary(dark_mode)), - ); + ); + + // Address selector: prev/next arrows + if self.selected_address_index > 0 && ui.small_button("<").clicked() { + self.selected_address_index -= 1; + } + if self.selected_address_index + 1 < self.address_count + && ui.small_button(">").clicked() + { + self.selected_address_index += 1; + } + + // Generate new diversified address + if ui + .small_button("+") + .on_hover_text("Generate new diversified address") + .clicked() + { + self.selected_address_index = self.address_count; + self.address_count += 1; + } + }); ui.add_space(4.0); ui.horizontal(|ui| { - let truncated = if addr.len() > 20 { - format!("{}...{}", &addr[..10], &addr[addr.len() - 10..]) - } else { - addr.clone() - }; - ui.monospace(&truncated); + ui.monospace(addr); if ui.small_button("Copy").clicked() { let _ = copy_text_to_clipboard(addr); } @@ -344,6 +418,23 @@ impl ShieldedTabView { ); } + let shield_core_btn = egui::Button::new( + RichText::new("Shield from Core") + .color(Color32::WHITE) + .size(14.0), + ) + .fill(DashColors::DASH_BLUE); + if ui + .add_enabled(!self.syncing, shield_core_btn) + .on_hover_text("Shield core DASH directly into the shielded pool via asset lock") + .clicked() + { + action |= AppAction::AddScreen( + ScreenType::ShieldFromAssetLockScreen(self.seed_hash) + .create_screen(&self.app_context), + ); + } + let can_spend = !self.syncing && self.tree_synced && self.shielded_balance > 0; let send_btn = egui::Button::new( @@ -383,48 +474,108 @@ impl ShieldedTabView { .create_screen(&self.app_context), ); } - - ui.add_space(10.0); - - if self.syncing { - ui.add(egui::Spinner::new().color(DashColors::DASH_BLUE)); - ui.label("Syncing..."); - } else if ui.button("Sync Notes").clicked() { - self.syncing = true; - self.success_message = None; - self.error_message = None; - action |= - AppAction::BackendTask(BackendTask::ShieldedTask(ShieldedTask::SyncNotes { - seed_hash: self.seed_hash, - })); - } }); ui.add_space(15.0); - // Notes table - let notes_info: Vec<(u64, u64, bool)> = { + // Notes section header with sync status and buttons + let (notes_info, synced_index): (Vec<(u64, u64, bool)>, u64) = { let states = self.app_context.shielded_states.lock().unwrap(); states .get(&self.seed_hash) .map(|state| { - state + let notes = state .notes .iter() .map(|n| (n.value, n.block_height, n.is_spent)) - .collect() + .collect(); + (notes, state.last_synced_index) }) .unwrap_or_default() }; - if !notes_info.is_empty() { + ui.horizontal(|ui| { ui.label( RichText::new("Shielded Notes") .size(16.0) .color(DashColors::text_primary(dark_mode)), ); - ui.add_space(5.0); + if !notes_info.is_empty() { + ui.label( + RichText::new(format!( + "(synced to index {}, {} our notes)", + synced_index, + notes_info.len() + )) + .size(12.0) + .color(DashColors::text_secondary(dark_mode)), + ); + } + + // Sync status indicator + if self.syncing { + ui.add(egui::Spinner::new().color(DashColors::DASH_BLUE)); + ui.label( + RichText::new("Syncing...") + .size(12.0) + .color(DashColors::DASH_BLUE), + ); + } else if self.tree_synced { + ui.label( + RichText::new("Synced") + .size(12.0) + .color(Color32::DARK_GREEN), + ); + } + + // Sync buttons + if !self.syncing { + if ui.small_button("Sync Notes").clicked() { + self.syncing = true; + self.success_message = None; + self.error_message = None; + action |= AppAction::BackendTask(BackendTask::ShieldedTask( + ShieldedTask::SyncNotes { + seed_hash: self.seed_hash, + }, + )); + } + + if self.app_context.is_developer_mode() && ui.small_button("Resync Notes").clicked() + { + // Remove in-memory state entirely (will be recreated by init) + { + let mut states = self.app_context.shielded_states.lock().unwrap(); + states.remove(&self.seed_hash); + } + // Clear persisted notes and commitment tree data + let network_str = self.app_context.network.to_string(); + let _ = self + .app_context + .db + .delete_shielded_notes(&self.seed_hash, &network_str); + let _ = self.app_context.db.clear_commitment_tree_tables(); + + self.shielded_balance = 0; + self.tree_synced = false; + self.is_initialized = false; + self.initializing = true; + self.syncing = false; + self.success_message = None; + self.error_message = None; + // Re-initialize (creates fresh persistent tree) then auto-syncs + action |= AppAction::BackendTask(BackendTask::ShieldedTask( + ShieldedTask::InitializeShieldedWallet { + seed_hash: self.seed_hash, + }, + )); + } + } + }); + ui.add_space(5.0); + + if !notes_info.is_empty() { egui::Grid::new("shielded_notes_grid") .num_columns(3) .striped(true) @@ -452,7 +603,7 @@ impl ShieldedTabView { ui.end_row(); } }); - } else { + } else if !self.syncing { ui.label( RichText::new("No shielded notes yet. Shield some credits to get started.") .color(DashColors::text_secondary(dark_mode)), diff --git a/src/ui/wallets/unshield_credits_screen.rs b/src/ui/wallets/unshield_credits_screen.rs index 3b86a6200..749b320ad 100644 --- a/src/ui/wallets/unshield_credits_screen.rs +++ b/src/ui/wallets/unshield_credits_screen.rs @@ -9,8 +9,10 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::{MessageType, RootScreenType, ScreenLike}; use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; +use dash_sdk::dpp::dashcore::Address; use eframe::egui::{self, Context}; use egui::{Color32, RichText}; +use std::str::FromStr; use std::sync::Arc; #[derive(PartialEq)] @@ -20,11 +22,19 @@ enum Status { Complete, } +/// Which kind of destination was parsed from the address input. +enum Destination { + /// Shielded pool → platform address (Type 17 Unshield) + Platform(PlatformAddress), + /// Shielded pool → core L1 address (Type 19 ShieldedWithdrawal) + Core(Address), +} + pub struct UnshieldCreditsScreen { pub app_context: Arc, pub seed_hash: WalletSeedHash, amount_str: String, - to_platform_address: Option, + address_str: String, max_balance: u64, status: Status, error_message: Option, @@ -41,24 +51,11 @@ impl UnshieldCreditsScreen { .unwrap_or(0) }; - // Try to find the first platform address from the wallet - let to_platform_address = { - let wallets = app_context.wallets.read().unwrap(); - wallets.get(&seed_hash).and_then(|w| { - let wallet = w.read().unwrap(); - wallet - .platform_address_info - .keys() - .next() - .and_then(|addr| PlatformAddress::try_from(addr.clone()).ok()) - }) - }; - Self { app_context: app_context.clone(), seed_hash, amount_str: String::new(), - to_platform_address, + address_str: String::new(), max_balance, status: Status::NotStarted, error_message: None, @@ -85,6 +82,30 @@ impl UnshieldCreditsScreen { Some(credits) } } + + /// Parse the address field into a Destination. + /// + /// Tries platform address (Bech32m tdash1.../dash1...) first, then falls + /// back to a core address (Base58 P2PKH/P2SH). + fn parse_destination(&self) -> Option { + let s = self.address_str.trim(); + if s.is_empty() { + return None; + } + + // Try platform address first + if let Ok((pa, _network)) = PlatformAddress::from_bech32m_string(s) { + return Some(Destination::Platform(pa)); + } + + // Try core address + if let Ok(addr) = Address::from_str(s) { + let addr = addr.require_network(self.app_context.network).ok()?; + return Some(Destination::Core(addr)); + } + + None + } } impl ScreenLike for UnshieldCreditsScreen { @@ -108,7 +129,9 @@ impl ScreenLike for UnshieldCreditsScreen { island_central_panel(ctx, |ui| { ui.heading("Unshield Credits"); ui.add_space(10.0); - ui.label("Move credits from the shielded pool back to a platform address."); + ui.label( + "Move credits from the shielded pool to a platform address or a core DASH address.", + ); ui.add_space(5.0); let dash_balance = self.max_balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; @@ -132,24 +155,39 @@ impl ScreenLike for UnshieldCreditsScreen { return; } - // Destination address display - if let Some(addr) = &self.to_platform_address { - ui.horizontal(|ui| { - ui.label("To platform address:"); - ui.monospace(format!("{}", addr)); - }); - ui.add_space(10.0); - } else { - ui.colored_label( - Color32::from_rgb(255, 100, 100), - "No platform address found. Register an identity first.", - ); - return; + // Destination address input + ui.horizontal(|ui| { + ui.label("To address:"); + ui.text_edit_singleline(&mut self.address_str); + }); + + // Show what was parsed + match self.parse_destination() { + Some(Destination::Platform(_)) => { + ui.colored_label( + Color32::DARK_GREEN, + "Platform address — will unshield to platform (Type 17)", + ); + } + Some(Destination::Core(_)) => { + ui.colored_label( + Color32::DARK_GREEN, + "Core address — will withdraw to core DASH (Type 19)", + ); + } + None if !self.address_str.trim().is_empty() => { + ui.colored_label( + Color32::from_rgb(255, 100, 100), + "Unrecognised address — enter a platform address (tdash1…/dash1…) or a core DASH address", + ); + } + None => {} } + ui.add_space(10.0); // Amount input ui.horizontal(|ui| { - ui.label("Amount (DASH or credits):"); + ui.label("Amount (DASH):"); ui.text_edit_singleline(&mut self.amount_str); }); if let Some(credits) = self.parse_amount_credits() { @@ -161,42 +199,61 @@ impl ScreenLike for UnshieldCreditsScreen { } ui.add_space(15.0); - // Confirm let amount_ok = self .parse_amount_credits() .is_some_and(|a| a <= self.max_balance); - let can_confirm = self.status == Status::NotStarted - && amount_ok - && self.to_platform_address.is_some(); + let destination = self.parse_destination(); + let can_confirm = + self.status == Status::NotStarted && amount_ok && destination.is_some(); if self.status == Status::WaitingForResult { ui.horizontal(|ui| { ui.add(egui::Spinner::new()); - ui.label("Unshielding credits..."); + ui.label("Processing..."); }); } else { ui.horizontal(|ui| { + let btn_label = match &destination { + Some(Destination::Core(_)) => "Withdraw to Core", + _ => "Unshield", + }; + if ui .add_enabled( can_confirm, egui::Button::new( - RichText::new("Unshield").color(Color32::WHITE).size(16.0), + RichText::new(btn_label).color(Color32::WHITE).size(16.0), ) .fill(crate::ui::theme::DashColors::DASH_BLUE), ) .clicked() - && let (Some(amount), Some(addr)) = - (self.parse_amount_credits(), self.to_platform_address) + && let Some(amount) = self.parse_amount_credits() { - self.status = Status::WaitingForResult; - self.error_message = None; - action = AppAction::BackendTask(BackendTask::ShieldedTask( - ShieldedTask::UnshieldCredits { - seed_hash: self.seed_hash, - amount, - to_platform_address: addr, - }, - )); + match self.parse_destination() { + Some(Destination::Platform(addr)) => { + self.status = Status::WaitingForResult; + self.error_message = None; + action = AppAction::BackendTask(BackendTask::ShieldedTask( + ShieldedTask::UnshieldCredits { + seed_hash: self.seed_hash, + amount, + to_platform_address: addr, + }, + )); + } + Some(Destination::Core(addr)) => { + self.status = Status::WaitingForResult; + self.error_message = None; + action = AppAction::BackendTask(BackendTask::ShieldedTask( + ShieldedTask::ShieldedWithdrawal { + seed_hash: self.seed_hash, + amount, + to_core_address: addr, + }, + )); + } + None => {} + } } ui.add_space(10.0); @@ -217,7 +274,20 @@ impl ScreenLike for UnshieldCreditsScreen { { self.status = Status::Complete; let dash = amount as f64 / CREDITS_PER_DUFF as f64 / 1e8; - self.success_message = Some(format!("Successfully unshielded {:.8} DASH", dash)); + self.success_message = Some(format!( + "Successfully unshielded {:.8} DASH to platform address", + dash + )); + } + BackendTaskSuccessResult::ShieldedWithdrawalComplete { seed_hash, amount } + if seed_hash == self.seed_hash => + { + self.status = Status::Complete; + let dash = amount as f64 / CREDITS_PER_DUFF as f64 / 1e8; + self.success_message = Some(format!( + "Successfully withdrew {:.8} DASH to core address", + dash + )); } _ => {} } diff --git a/src/ui/wallets/wallets_screen/address_table.rs b/src/ui/wallets/wallets_screen/address_table.rs index 2e92c4af8..060cc86f5 100644 --- a/src/ui/wallets/wallets_screen/address_table.rs +++ b/src/ui/wallets/wallets_screen/address_table.rs @@ -34,6 +34,8 @@ pub(super) struct AddressData { platform_credits: u64, utxo_count: usize, total_received: u64, + /// Platform address nonce (for Platform Payment addresses) + nonce: u32, address_type: String, index: u32, derivation_path: DerivationPath, @@ -43,7 +45,7 @@ pub(super) struct AddressData { impl AddressData { /// Returns the address formatted for display. - /// Platform Payment addresses are shown in DIP-18 Bech32m format (e.g., tevo1...). + /// Platform Payment addresses are shown in DIP-18 Bech32m format (e.g., tdash1...). fn display_address(&self, network: Network) -> String { if self.account_category == AccountCategory::PlatformPayment { use dash_sdk::dpp::address_funds::PlatformAddress; @@ -156,12 +158,12 @@ impl WalletsBalancesScreen { let (account_category, account_index) = Self::categorize_path(derivation_path, path_reference); - // Get Platform credits balance for Platform Payment addresses + // Get Platform credits balance and nonce for Platform Payment addresses // Use canonical lookup to handle potential Address key mismatches - let platform_credits = wallet - .get_platform_address_info(address) - .map(|info| info.balance) - .unwrap_or_default(); + let platform_info = wallet.get_platform_address_info(address); + let platform_credits = + platform_info.map(|info| info.balance).unwrap_or_default(); + let nonce = platform_info.map(|info| info.nonce).unwrap_or_default(); AddressData { address: address.clone(), @@ -173,6 +175,7 @@ impl WalletsBalancesScreen { platform_credits, utxo_count, total_received, + nonce, address_type, index, derivation_path: derivation_path.clone(), @@ -194,17 +197,32 @@ impl WalletsBalancesScreen { // Space allocation for UI elements is handled by the layout system + let is_platform_account = self + .selected_account + .as_ref() + .map(|(cat, _)| *cat == AccountCategory::PlatformPayment) + .unwrap_or(false); + // Render the table - TableBuilder::new(ui) + let mut builder = TableBuilder::new(ui) .id_salt("addresses_table") .striped(false) .resizable(true) .vscroll(false) .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) .column(Column::auto()) // Address - .column(Column::initial(140.0)) // Balance - .column(Column::initial(70.0)) // UTXOs - .column(Column::initial(150.0)) // Total Received + .column(Column::initial(140.0)); // Balance + + builder = if is_platform_account { + builder.column(Column::initial(80.0)) // Nonce (replaces UTXOs) + // Total Received column omitted + } else { + builder + .column(Column::initial(70.0)) // UTXOs + .column(Column::initial(150.0)) // Total Received + }; + + builder .column(Column::initial(100.0)) // Type .column(Column::initial(70.0)) // Index .column(Column::initial(120.0)) // Derivation Path @@ -236,32 +254,38 @@ impl WalletsBalancesScreen { self.toggle_sort(SortColumn::Balance); } }); - header.col(|ui| { - let label = if self.sort_column == SortColumn::UTXOs { - match self.sort_order { - SortOrder::Ascending => "UTXOs ^", - SortOrder::Descending => "UTXOs v", + if is_platform_account { + header.col(|ui| { + ui.label("Nonce"); + }); + } else { + header.col(|ui| { + let label = if self.sort_column == SortColumn::UTXOs { + match self.sort_order { + SortOrder::Ascending => "UTXOs ^", + SortOrder::Descending => "UTXOs v", + } + } else { + "UTXOs" + }; + if ui.button(label).clicked() { + self.toggle_sort(SortColumn::UTXOs); } - } else { - "UTXOs" - }; - if ui.button(label).clicked() { - self.toggle_sort(SortColumn::UTXOs); - } - }); - header.col(|ui| { - let label = if self.sort_column == SortColumn::TotalReceived { - match self.sort_order { - SortOrder::Ascending => "Total Received (DASH) ^", - SortOrder::Descending => "Total Received (DASH) v", + }); + header.col(|ui| { + let label = if self.sort_column == SortColumn::TotalReceived { + match self.sort_order { + SortOrder::Ascending => "Total Received (DASH) ^", + SortOrder::Descending => "Total Received (DASH) v", + } + } else { + "Total Received (DASH)" + }; + if ui.button(label).clicked() { + self.toggle_sort(SortColumn::TotalReceived); } - } else { - "Total Received (DASH)" - }; - if ui.button(label).clicked() { - self.toggle_sort(SortColumn::TotalReceived); - } - }); + }); + }; header.col(|ui| { let label = if self.sort_column == SortColumn::Type { match self.sort_order { @@ -320,8 +344,6 @@ impl WalletsBalancesScreen { if is_key_only { ui.label("N/A"); } else if is_platform_payment { - // Platform credits: convert from credits to DASH - // Credits are in duffs * 1000, so divide by 1000 then by 1e8 let dash_balance = data.platform_credits as f64 / CREDITS_PER_DUFF as f64 / 1e8; ui.label(format!("{:.8}", dash_balance)); @@ -330,23 +352,27 @@ impl WalletsBalancesScreen { ui.label(format!("{:.8}", dash_balance)); } }); - row.col(|ui| { - // Key-only addresses and Platform addresses don't hold UTXOs - if is_key_only || is_platform_payment { - ui.label("N/A"); - } else { - ui.label(format!("{}", data.utxo_count)); - } - }); - row.col(|ui| { - // These address types don't track historical received amounts - if is_key_only || is_platform_payment { - ui.label("N/A"); - } else { - let dash_received = data.total_received as f64 * 1e-8; - ui.label(format!("{:.8}", dash_received)); - } - }); + if is_platform_account { + row.col(|ui| { + ui.label(format!("{}", data.nonce)); + }); + } else { + row.col(|ui| { + if is_key_only { + ui.label("N/A"); + } else { + ui.label(format!("{}", data.utxo_count)); + } + }); + row.col(|ui| { + if is_key_only { + ui.label("N/A"); + } else { + let dash_received = data.total_received as f64 * 1e-8; + ui.label(format!("{:.8}", dash_received)); + } + }); + }; row.col(|ui| { ui.label(&data.address_type); }); diff --git a/src/ui/wallets/wallets_screen/dialogs.rs b/src/ui/wallets/wallets_screen/dialogs.rs index 23a71f366..a256b0cf8 100644 --- a/src/ui/wallets/wallets_screen/dialogs.rs +++ b/src/ui/wallets/wallets_screen/dialogs.rs @@ -79,6 +79,16 @@ pub(super) struct FundPlatformAddressDialogState { pub pending_fund_after_unlock: bool, } +/// State for the Mine Blocks dialog (dev mode, Regtest/Devnet only) +#[derive(Default)] +pub(super) struct MineDialogState { + pub is_open: bool, + pub core_addresses: Vec<(String, u64)>, + pub selected_address_index: usize, + pub block_count_str: String, + pub error: Option, +} + /// State for the Private Key dialog #[derive(Default)] pub(super) struct PrivateKeyDialogState { @@ -580,7 +590,7 @@ impl WalletsBalancesScreen { } /// Generate a new Platform address for the wallet. - /// Returns the address in Bech32m format (e.g., tevo1... for testnet) + /// Returns the address in Bech32m format (e.g., tdash1... for testnet) pub(super) fn generate_platform_address( &self, wallet: &Arc>, @@ -945,10 +955,8 @@ impl WalletsBalancesScreen { return AppAction::None; }; - // Parse the Platform address (Bech32m format: evo1.../tevo1...) - let platform_addr = if selected_addr.starts_with("evo1") - || selected_addr.starts_with("tevo1") - { + // Parse the Platform address (Bech32m format: dash1.../tdash1...) + let platform_addr = if crate::ui::helpers::is_platform_address(selected_addr) { match PlatformAddress::from_bech32m_string(selected_addr) { Ok((addr, network)) => { // Validate that address network matches app network @@ -1192,4 +1200,230 @@ impl WalletsBalancesScreen { let private_key = wallet.private_key_at_derivation_path(path, self.app_context.network)?; Ok(private_key.to_wif()) } + + pub(super) fn open_mine_dialog(&mut self) { + let Some(wallet) = self.selected_wallet.clone() else { + self.mine_dialog.error = Some("Select a wallet first".to_string()); + self.mine_dialog.is_open = true; + return; + }; + + self.mine_dialog = MineDialogState { + is_open: true, + block_count_str: "1".to_string(), + ..Default::default() + }; + + // Reuse the same address loading pattern as receive dialog + self.load_core_addresses_for_mine(&wallet); + } + + fn load_core_addresses_for_mine(&mut self, wallet: &Arc>) { + let wallet_guard = match wallet.read() { + Ok(guard) => guard, + Err(err) => { + self.mine_dialog.error = Some(err.to_string()); + return; + } + }; + + let network = self.app_context.network; + let core_addresses: Vec<(String, u64)> = wallet_guard + .watched_addresses + .iter() + .filter(|(path, _)| path.is_bip44_external(network)) + .map(|(_, info)| { + let balance = wallet_guard + .address_balances + .get(&info.address) + .copied() + .unwrap_or(0); + (info.address.to_string(), balance) + }) + .collect(); + + drop(wallet_guard); + + if core_addresses.is_empty() { + match self.generate_new_core_receive_address(wallet) { + Ok((address, balance)) => { + self.mine_dialog.core_addresses = vec![(address, balance)]; + self.mine_dialog.selected_address_index = 0; + } + Err(err) => { + self.mine_dialog.error = Some(err); + } + } + } else { + self.mine_dialog.core_addresses = core_addresses; + self.mine_dialog.selected_address_index = 0; + } + } + + pub(super) fn render_mine_dialog(&mut self, ctx: &Context) -> AppAction { + if !self.mine_dialog.is_open { + return AppAction::None; + } + + let mut action = AppAction::None; + let mut open = self.mine_dialog.is_open; + let dark_mode = ctx.style().visuals.dark_mode; + + Self::draw_modal_overlay(ctx, "mine_dialog_overlay"); + + egui::Window::new("Mine Blocks") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) + .open(&mut open) + .frame(Self::modal_frame(ctx)) + .show(ctx, |ui| { + ui.set_min_width(350.0); + ui.vertical(|ui| { + ui.label( + RichText::new("Mine blocks to a wallet address:") + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(10.0); + + // Address selector + if !self.mine_dialog.core_addresses.is_empty() { + ui.label("Address:"); + ComboBox::from_id_salt("mine_addr_selector") + .selected_text( + self.mine_dialog + .core_addresses + .get(self.mine_dialog.selected_address_index) + .map(|(addr, balance)| { + let balance_dash = *balance as f64 / 1e8; + format!( + "{}... ({:.4} DASH)", + &addr[..12.min(addr.len())], + balance_dash + ) + }) + .unwrap_or_default(), + ) + .width(ui.available_width() - 16.0) + .show_ui(ui, |ui| { + for (idx, (addr, balance)) in + self.mine_dialog.core_addresses.iter().enumerate() + { + let balance_dash = *balance as f64 / 1e8; + let label = format!( + "{}... ({:.4} DASH)", + &addr[..12.min(addr.len())], + balance_dash + ); + if ui + .selectable_label( + idx == self.mine_dialog.selected_address_index, + label, + ) + .clicked() + { + self.mine_dialog.selected_address_index = idx; + } + } + }); + } + + ui.add_space(10.0); + + // Block count input + ui.label("Number of blocks:"); + ui.add( + egui::TextEdit::singleline(&mut self.mine_dialog.block_count_str) + .hint_text("1") + .desired_width(100.0), + ); + + // Error display + if let Some(error) = &self.mine_dialog.error { + ui.add_space(8.0); + ui.label(RichText::new(error).color(DashColors::error_color(dark_mode))); + } + + ui.add_space(15.0); + + // Buttons + ui.horizontal(|ui| { + let cancel_button = egui::Button::new( + RichText::new("Cancel").color(DashColors::text_primary(dark_mode)), + ) + .fill(egui::Color32::TRANSPARENT) + .stroke(egui::Stroke::new( + 1.0, + DashColors::text_secondary(dark_mode), + )) + .corner_radius(egui::CornerRadius::same(4)) + .min_size(egui::Vec2::new(80.0, 32.0)); + + if ui.add(cancel_button).clicked() { + self.mine_dialog = MineDialogState::default(); + } + + ui.add_space(8.0); + + let mine_button = + egui::Button::new(RichText::new("Mine").color(egui::Color32::WHITE)) + .fill(DashColors::DASH_BLUE) + .corner_radius(egui::CornerRadius::same(4)) + .min_size(egui::Vec2::new(80.0, 32.0)); + + if ui.add(mine_button).clicked() { + // Validate and dispatch + let block_count: u64 = + match self.mine_dialog.block_count_str.trim().parse() { + Ok(n) if n > 0 => n, + _ => { + self.mine_dialog.error = Some( + "Enter a valid number of blocks (> 0)".to_string(), + ); + return; + } + }; + + let Some((addr_str, _)) = self + .mine_dialog + .core_addresses + .get(self.mine_dialog.selected_address_index) + else { + self.mine_dialog.error = Some("No address selected".to_string()); + return; + }; + + let address = match addr_str.parse::>() { + Ok(addr) => addr.assume_checked(), + Err(e) => { + self.mine_dialog.error = + Some(format!("Invalid address: {}", e)); + return; + } + }; + + let Some(wallet) = self.selected_wallet.clone() else { + self.mine_dialog.error = Some("No wallet selected".to_string()); + return; + }; + + action = AppAction::BackendTask(BackendTask::CoreTask( + CoreTask::MineBlocks { + block_count, + address, + wallet, + }, + )); + self.mine_dialog = MineDialogState::default(); + } + }); + }); + }); + + self.mine_dialog.is_open = open; + if !self.mine_dialog.is_open { + self.mine_dialog = MineDialogState::default(); + } + action + } } diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index d5a2550fc..8a788e88f 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -9,7 +9,7 @@ use crate::backend_task::core::CoreTask; use crate::context::AppContext; use crate::model::amount::Amount; use crate::model::wallet::{Wallet, WalletSeedHash, WalletTransaction}; -use crate::spv::CoreBackendMode; +use crate::spv::{CoreBackendMode, SpvStatus}; use crate::ui::components::component_trait::Component; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::left_panel::add_left_panel; @@ -23,6 +23,7 @@ use crate::ui::wallets::account_summary::{ }; use crate::ui::{MessageType, RootScreenType, ScreenLike, ScreenType}; use chrono::{DateTime, Utc}; +use dash_sdk::dash_spv::sync::{ProgressPercentage, SyncProgress as SpvSyncProgress, SyncState}; use dash_sdk::dashcore_rpc::dashcore::Address; use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; use eframe::egui::{self, ComboBox, Context, Ui}; @@ -34,7 +35,8 @@ use crate::model::wallet::single_key::SingleKeyWallet; use crate::ui::wallets::shielded_tab::ShieldedTabView; use address_table::{SortColumn, SortOrder}; use dialogs::{ - FundPlatformAddressDialogState, PrivateKeyDialogState, ReceiveDialogState, SendDialogState, + FundPlatformAddressDialogState, MineDialogState, PrivateKeyDialogState, ReceiveDialogState, + SendDialogState, }; /// Tab selector for the wallet detail panel. @@ -48,30 +50,21 @@ enum WalletViewTab { /// Refresh mode for dev mode dropdown - controls what gets refreshed #[derive(Clone, Copy, PartialEq, Eq, Default)] enum RefreshMode { - /// Current behavior: Core wallet + Platform (auto decides full vs terminal) + /// Core wallet + Platform address sync #[default] All, /// Only refresh Core wallet balances CoreOnly, - /// Only Platform sync - force full sync - PlatformFull, - /// Only Platform sync - terminal only - PlatformTerminal, - /// Core wallet + Platform full sync - CoreAndPlatformFull, - /// Core wallet + Platform terminal sync - CoreAndPlatformTerminal, + /// Only Platform address sync + PlatformOnly, } impl RefreshMode { fn label(&self) -> &'static str { match self { - RefreshMode::All => "All (Auto)", + RefreshMode::All => "Core + Platform", RefreshMode::CoreOnly => "Core Only", - RefreshMode::PlatformFull => "Platform (Full)", - RefreshMode::PlatformTerminal => "Platform (Terminal)", - RefreshMode::CoreAndPlatformFull => "Core + Platform (Full)", - RefreshMode::CoreAndPlatformTerminal => "Core + Platform (Terminal)", + RefreshMode::PlatformOnly => "Platform Only", } } @@ -79,10 +72,7 @@ impl RefreshMode { &[ RefreshMode::All, RefreshMode::CoreOnly, - RefreshMode::PlatformFull, - RefreshMode::PlatformTerminal, - RefreshMode::CoreAndPlatformFull, - RefreshMode::CoreAndPlatformTerminal, + RefreshMode::PlatformOnly, ] } } @@ -109,6 +99,7 @@ pub struct WalletsBalancesScreen { receive_dialog: ReceiveDialogState, fund_platform_dialog: FundPlatformAddressDialogState, private_key_dialog: PrivateKeyDialogState, + mine_dialog: MineDialogState, selected_account: Option<(AccountCategory, Option)>, /// Pending refresh of platform address balances (triggered after transfers) pending_platform_balance_refresh: Option, @@ -126,6 +117,8 @@ pub struct WalletsBalancesScreen { selected_tab: WalletViewTab, /// Shielded tab view component (lazily initialized per wallet) shielded_tab_view: Option, + /// Cached platform sync info: (last_sync_timestamp, last_sync_height) + platform_sync_info: Option<(u64, u64)>, } impl WalletsBalancesScreen { @@ -183,6 +176,12 @@ impl WalletsBalancesScreen { selected_wallet: Option>>, selected_single_key_wallet: Option>>, ) -> Self { + let platform_sync_info = selected_wallet + .as_ref() + .and_then(|w| w.read().ok().map(|g| g.seed_hash())) + .and_then(|hash| app_context.db.get_platform_sync_info(&hash).ok()) + .filter(|(ts, _)| *ts > 0); + Self { selected_wallet, selected_single_key_wallet, @@ -205,6 +204,7 @@ impl WalletsBalancesScreen { receive_dialog: ReceiveDialogState::default(), fund_platform_dialog: FundPlatformAddressDialogState::default(), private_key_dialog: PrivateKeyDialogState::default(), + mine_dialog: MineDialogState::default(), selected_account: None, pending_platform_balance_refresh: None, pending_refresh_after_unlock: false, @@ -214,6 +214,7 @@ impl WalletsBalancesScreen { refresh_mode: RefreshMode::default(), selected_tab: WalletViewTab::default(), shielded_tab_view: None, + platform_sync_info, } } @@ -246,10 +247,21 @@ impl WalletsBalancesScreen { if let Ok(hash) = wallet.read().map(|g| g.seed_hash()) { self.persist_selected_wallet_hash(Some(hash)); + self.refresh_platform_sync_info_cache(&hash); } self.persist_selected_single_key_hash(None); } + /// Refresh the cached platform sync info from the database. + fn refresh_platform_sync_info_cache(&mut self, seed_hash: &WalletSeedHash) { + self.platform_sync_info = self + .app_context + .db + .get_platform_sync_info(seed_hash) + .ok() + .filter(|(ts, _)| *ts > 0); + } + fn select_single_key_wallet(&mut self, wallet: Arc>) { self.selected_single_key_wallet = Some(wallet.clone()); self.selected_wallet = None; @@ -809,6 +821,34 @@ impl WalletsBalancesScreen { Amount::dash_from_duffs(amount_duffs).to_string() } + /// Format a `std::time::Instant` as a relative "time ago" string. + fn format_instant_ago(instant: std::time::Instant) -> String { + Self::format_duration_ago(instant.elapsed()) + } + + /// Format a Unix timestamp (seconds since epoch) as a relative "time ago" string. + fn format_unix_time_ago(unix_ts: u64) -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let elapsed_secs = now.saturating_sub(unix_ts); + Self::format_duration_ago(std::time::Duration::from_secs(elapsed_secs)) + } + + fn format_duration_ago(duration: std::time::Duration) -> String { + let secs = duration.as_secs(); + if secs < 60 { + format!("{}s ago", secs) + } else if secs < 3600 { + format!("{}m ago", secs / 60) + } else if secs < 86400 { + format!("{}h ago", secs / 3600) + } else { + format!("{}d ago", secs / 86400) + } + } + fn transaction_direction_label(tx: &WalletTransaction) -> &'static str { if tx.is_incoming() { "Received" @@ -907,6 +947,23 @@ impl WalletsBalancesScreen { { action |= self.open_receive_dialog(ctx); } + + if matches!( + self.app_context.network, + dash_sdk::dpp::dashcore::Network::Regtest + | dash_sdk::dpp::dashcore::Network::Devnet + ) && self.app_context.is_developer_mode() + && self.app_context.core_backend_mode() == CoreBackendMode::Rpc + && ui + .button( + RichText::new("Mine") + .color(DashColors::text_primary(dark_mode)) + .strong(), + ) + .clicked() + { + self.open_mine_dialog(); + } }); action } @@ -1100,6 +1157,300 @@ impl WalletsBalancesScreen { }); } + /// Render a compact sync status panel showing Core and Platform sync progress. + fn render_sync_status(&self, ui: &mut Ui) { + let dark_mode = ui.ctx().style().visuals.dark_mode; + + Frame::group(ui.style()) + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(16, 8)) + .show(ui, |ui| { + // Line 1 — Core sync status + ui.horizontal(|ui| { + ui.label( + RichText::new("Core:") + .size(12.0) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + + match self.app_context.core_backend_mode() { + CoreBackendMode::Rpc => { + if self.app_context.connection_status().rpc_online() { + ui.colored_label( + Color32::DARK_GREEN, + RichText::new("Connected").size(12.0), + ); + } else { + ui.colored_label( + DashColors::ERROR, + RichText::new("Disconnected").size(12.0), + ); + } + } + CoreBackendMode::Spv => { + let snapshot = self.app_context.spv_manager().status(); + match snapshot.status { + SpvStatus::Idle | SpvStatus::Stopped => { + ui.label( + RichText::new("Disconnected") + .size(12.0) + .color(DashColors::text_secondary(dark_mode)), + ); + } + SpvStatus::Starting => { + ui.add( + egui::Spinner::new() + .size(12.0) + .color(DashColors::DASH_BLUE), + ); + ui.label( + RichText::new("Connecting...") + .size(12.0) + .color(DashColors::DASH_BLUE), + ); + } + SpvStatus::Syncing => { + ui.add( + egui::Spinner::new() + .size(12.0) + .color(DashColors::DASH_BLUE), + ); + let phase_text = + Self::spv_active_phase_text(&snapshot.sync_progress); + ui.label( + RichText::new(format!("Syncing — {phase_text}")) + .size(12.0) + .color(DashColors::DASH_BLUE), + ); + } + SpvStatus::Running => { + ui.colored_label( + Color32::DARK_GREEN, + RichText::new(format!( + "Synced — {} peers", + snapshot.connected_peers + )) + .size(12.0), + ); + } + SpvStatus::Stopping => { + ui.add( + egui::Spinner::new() + .size(12.0) + .color(DashColors::DASH_BLUE), + ); + ui.label( + RichText::new("Disconnecting...") + .size(12.0) + .color(DashColors::DASH_BLUE), + ); + } + SpvStatus::Error => { + ui.colored_label( + DashColors::ERROR, + RichText::new("Error").size(12.0), + ); + } + } + } + } + }); + + // Line 2 — Platform sync status + ui.horizontal(|ui| { + ui.label( + RichText::new("Platform:") + .size(12.0) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + + // Addresses + let addr_count = self + .selected_wallet + .as_ref() + .and_then(|w| w.read().ok()) + .map(|w| w.platform_address_info.len()) + .unwrap_or(0); + if self.refreshing { + ui.add(egui::Spinner::new().size(12.0).color(DashColors::DASH_BLUE)); + } + let addr_text = + if let Some((last_sync_ts, sync_height)) = self.platform_sync_info { + let ago = Self::format_unix_time_ago(last_sync_ts); + format!( + "Addresses: {} synced (blk {}, {})", + addr_count, sync_height, ago + ) + } else { + "Addresses: never synced".to_string() + }; + ui.label( + RichText::new(addr_text) + .size(12.0) + .color(if self.refreshing { + DashColors::DASH_BLUE + } else { + DashColors::text_secondary(dark_mode) + }), + ); + + ui.label( + RichText::new("|") + .size(12.0) + .color(DashColors::text_secondary(dark_mode)), + ); + + // Shielded notes + nullifiers + let seed_hash = self + .selected_wallet + .as_ref() + .and_then(|w| w.read().ok().map(|g| g.seed_hash())); + let shielded_info = seed_hash.and_then(|hash| { + let states = self.app_context.shielded_states.lock().ok()?; + let state = states.get(&hash)?; + Some(( + state.last_synced_index, + state.notes.iter().filter(|n| !n.is_spent).count(), + state.last_nullifier_sync_height, + state.last_notes_synced_at, + state.last_nullifiers_synced_at, + )) + }); + let shielded_syncing = self + .shielded_tab_view + .as_ref() + .is_some_and(|v| v.is_syncing()); + + match shielded_info { + Some(( + synced_index, + note_count, + nf_height, + notes_synced_at, + nf_synced_at, + )) => { + if shielded_syncing { + ui.add( + egui::Spinner::new().size(12.0).color(DashColors::DASH_BLUE), + ); + } + let notes_text = if let Some(t) = notes_synced_at { + let ago = Self::format_instant_ago(t); + format!( + "Notes: {} synced ({} notes, {})", + synced_index, note_count, ago + ) + } else if synced_index > 0 { + format!("Notes: {} synced ({} notes)", synced_index, note_count) + } else { + "Notes: never synced".to_string() + }; + ui.label(RichText::new(notes_text).size(12.0).color( + if shielded_syncing { + DashColors::DASH_BLUE + } else { + DashColors::text_secondary(dark_mode) + }, + )); + + ui.label( + RichText::new("|") + .size(12.0) + .color(DashColors::text_secondary(dark_mode)), + ); + + let nf_text = if let Some(t) = nf_synced_at { + let ago = Self::format_instant_ago(t); + format!("Nullifiers: height {} ({})", nf_height, ago) + } else if nf_height > 0 { + format!("Nullifiers: height {}", nf_height) + } else { + "Nullifiers: never synced".to_string() + }; + ui.label(RichText::new(nf_text).size(12.0).color( + if shielded_syncing { + DashColors::DASH_BLUE + } else { + DashColors::text_secondary(dark_mode) + }, + )); + } + None => { + ui.label( + RichText::new("Notes: never synced") + .size(12.0) + .color(DashColors::text_secondary(dark_mode)), + ); + ui.label( + RichText::new("|") + .size(12.0) + .color(DashColors::text_secondary(dark_mode)), + ); + ui.label( + RichText::new("Nullifiers: never synced") + .size(12.0) + .color(DashColors::text_secondary(dark_mode)), + ); + } + } + }); + }); + } + + /// Get a text summary of the active SPV sync phase. + fn spv_active_phase_text(sync_progress: &Option) -> String { + let Some(progress) = sync_progress else { + return "starting...".to_string(); + }; + + // Grab target height from headers (blocks doesn't expose target_height directly) + let target_height = progress + .headers() + .ok() + .map(|h| h.target_height()) + .unwrap_or(0); + + // Check phases in order of execution + if let Ok(headers) = progress.headers() + && headers.state() == SyncState::Syncing + { + let pct = Self::simple_progress_pct(headers.current_height(), headers.target_height()); + return format!("Headers {pct}%"); + } + + if let Ok(fh) = progress.filter_headers() + && fh.state() == SyncState::Syncing + { + let pct = Self::simple_progress_pct(fh.current_height(), fh.target_height()); + return format!("Filter Headers {pct}%"); + } + + if let Ok(filters) = progress.filters() + && filters.state() == SyncState::Syncing + { + let pct = Self::simple_progress_pct(filters.current_height(), filters.target_height()); + return format!("Filters {pct}%"); + } + + if let Ok(blocks) = progress.blocks() + && blocks.state() == SyncState::Syncing + { + let pct = Self::simple_progress_pct(blocks.last_processed(), target_height); + return format!("Blocks {pct}%"); + } + + "syncing...".to_string() + } + + fn simple_progress_pct(current: u32, target: u32) -> u32 { + if target == 0 { + return 0; + } + ((current as f64 / target as f64) * 100.0).clamp(0.0, 100.0) as u32 + } + fn render_wallet_detail_panel(&mut self, ui: &mut Ui, ctx: &Context) -> AppAction { let Some(wallet_arc) = self.selected_wallet.clone() else { self.render_no_wallets_view(ui); @@ -1315,8 +1666,6 @@ impl WalletsBalancesScreen { wallet_arc: &Arc>, mode: RefreshMode, ) -> AppAction { - use crate::backend_task::wallet::PlatformSyncMode; - let seed_hash = wallet_arc .read() .ok() @@ -1325,51 +1674,27 @@ impl WalletsBalancesScreen { match mode { RefreshMode::All => { - // Default behavior: Core + Platform (Auto) + // Core + Platform AppAction::BackendTask(BackendTask::CoreTask(CoreTask::RefreshWalletInfo( wallet_arc.clone(), - Some(PlatformSyncMode::Auto), + true, ))) } RefreshMode::CoreOnly => { // Core only, no Platform sync AppAction::BackendTask(BackendTask::CoreTask(CoreTask::RefreshWalletInfo( wallet_arc.clone(), - None, + false, ))) } - RefreshMode::PlatformFull => { - // Platform only with forced full sync - AppAction::BackendTask(BackendTask::WalletTask( - crate::backend_task::wallet::WalletTask::FetchPlatformAddressBalances { - seed_hash, - sync_mode: PlatformSyncMode::ForceFull, - }, - )) - } - RefreshMode::PlatformTerminal => { - // Platform only with terminal sync + RefreshMode::PlatformOnly => { + // Platform only AppAction::BackendTask(BackendTask::WalletTask( crate::backend_task::wallet::WalletTask::FetchPlatformAddressBalances { seed_hash, - sync_mode: PlatformSyncMode::TerminalOnly, }, )) } - RefreshMode::CoreAndPlatformFull => { - // Core + Platform with forced full sync - AppAction::BackendTask(BackendTask::CoreTask(CoreTask::RefreshWalletInfo( - wallet_arc.clone(), - Some(PlatformSyncMode::ForceFull), - ))) - } - RefreshMode::CoreAndPlatformTerminal => { - // Core + Platform with terminal sync - AppAction::BackendTask(BackendTask::CoreTask(CoreTask::RefreshWalletInfo( - wallet_arc.clone(), - Some(PlatformSyncMode::TerminalOnly), - ))) - } } } } @@ -1379,17 +1704,15 @@ impl ScreenLike for WalletsBalancesScreen { self.check_message_expiration(); // Check for pending platform balance refresh (triggered after transfers) - let pending_refresh_action = - if let Some(seed_hash) = self.pending_platform_balance_refresh.take() { - AppAction::BackendTask(BackendTask::WalletTask( - crate::backend_task::wallet::WalletTask::FetchPlatformAddressBalances { - seed_hash, - sync_mode: crate::backend_task::wallet::PlatformSyncMode::Auto, - }, - )) - } else { - AppAction::None - }; + let pending_refresh_action = if let Some(seed_hash) = + self.pending_platform_balance_refresh.take() + { + AppAction::BackendTask(BackendTask::WalletTask( + crate::backend_task::wallet::WalletTask::FetchPlatformAddressBalances { seed_hash }, + )) + } else { + AppAction::None + }; let mut right_buttons = vec![ ( @@ -1498,6 +1821,12 @@ impl ScreenLike for WalletsBalancesScreen { ui.add_space(10.0); + // Sync status panel (only for HD wallets) + if self.selected_wallet.is_some() { + self.render_sync_status(ui); + ui.add_space(6.0); + } + // Render the appropriate detail view based on selection if self.selected_wallet.is_some() { inner_action |= self.render_wallet_detail_panel(ui, ctx); @@ -1512,6 +1841,7 @@ impl ScreenLike for WalletsBalancesScreen { action |= self.render_send_dialog(ctx); action |= self.render_receive_dialog(ctx); action |= self.render_fund_platform_dialog(ctx); + action |= self.render_mine_dialog(ctx); self.render_private_key_dialog(ctx); // Rename dialog @@ -1864,6 +2194,14 @@ impl ScreenLike for WalletsBalancesScreen { match backend_task_success_result { crate::ui::BackendTaskSuccessResult::RefreshedWallet { warning } => { self.refreshing = false; + // Refresh platform sync info cache (the refresh may have included platform sync) + let hash = self + .selected_wallet + .as_ref() + .and_then(|w| w.read().ok().map(|g| g.seed_hash())); + if let Some(h) = hash { + self.refresh_platform_sync_info_cache(&h); + } if let Some(warn_msg) = warning { self.set_message( format!("Wallet refreshed with warning: {}", warn_msg), @@ -1969,6 +2307,7 @@ impl ScreenLike for WalletsBalancesScreen { wallet.set_platform_address_info(addr, balance, nonce); } } + self.refresh_platform_sync_info_cache(&seed_hash); self.set_message( "Successfully synced Platform balances".to_string(), MessageType::Success, @@ -1978,6 +2317,10 @@ impl ScreenLike for WalletsBalancesScreen { self.refreshing = false; self.display_message(&msg, MessageType::Success); } + crate::ui::BackendTaskSuccessResult::MineBlocksSuccess(count) => { + self.refreshing = false; + self.display_message(&format!("Mined {} block(s)", count), MessageType::Success); + } // Shielded pool results result @ (crate::ui::BackendTaskSuccessResult::ShieldedInitialized { .. } | crate::ui::BackendTaskSuccessResult::ShieldedNotesSynced { .. } From 0cde65fa7fc9e8c63e599dbac855ff34417280cd Mon Sep 17 00:00:00 2001 From: lklimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Feb 2026 09:11:42 +0100 Subject: [PATCH 003/147] fix(spv): zero out stale per-address balances during reconciliation (#627) During SPV reconciliation, per_address_sum only contains addresses with current UTXOs. Addresses whose funds were fully spent never had their balance reset to zero, causing the address table to display stale non-zero balances even though UTXO count correctly showed 0. Now explicitly zeroes address_balances for any known address absent from the refreshed UTXO map before applying current sums. Closes #571 Co-authored-by: Claude Opus 4.6 --- src/context/wallet_lifecycle.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index 2a952892f..88051de35 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -719,9 +719,21 @@ impl AppContext { // Update in-memory UTXOs map w.utxos = new_utxos; + // Zero out balances for known addresses that no longer have any UTXOs. + // Without this, spent addresses retain stale non-zero balances because + // per_address_sum only contains addresses with current UTXOs. + for addr in &known_addresses { + if !w.utxos.contains_key(addr) + && let Err(e) = w.update_address_balance(addr, 0, self) + { + tracing::debug!(address = %addr, error = %e, "Failed to zero spent address balance"); + } + } + for (addr, sum) in per_address_sum.into_iter() { - // Update wallet and DB through model helper - let _ = w.update_address_balance(&addr, sum, self); + if let Err(e) = w.update_address_balance(&addr, sum, self) { + tracing::debug!(address = %addr, error = %e, "Failed to update address balance"); + } } } From 0879c45ac5b9a3bf6d4d49c5bc75d344710935df Mon Sep 17 00:00:00 2001 From: lklimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Feb 2026 09:12:59 +0100 Subject: [PATCH 004/147] fix: handle malformed YAML gracefully in load_testnet_nodes_from_yml (#613) * fix: handle malformed YAML gracefully in load_testnet_nodes_from_yml Replace .expect() with match expression to avoid panicking when .testnet_nodes.yml contains malformed YAML. Instead, logs the error with tracing::error and returns None, allowing the application to continue without crashing. Closes #557 Co-authored-by: lklimek * fix: propagate YAML parse errors to UI and remove unwrap calls - Change load_testnet_nodes_from_yml to return Result, String> so parse errors display in the UI error banner instead of only logging - Use explicit type annotation on serde_yaml_ng::from_str:: - Replace .unwrap() with .and_then() in fill_random_hpmn/fill_random_masternode Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: lklimek Co-authored-by: Claude Opus 4.6 --- .../add_existing_identity_screen.rs | 37 +++++++++++-------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/src/ui/identities/add_existing_identity_screen.rs b/src/ui/identities/add_existing_identity_screen.rs index 4e0ad46d2..88b651007 100644 --- a/src/ui/identities/add_existing_identity_screen.rs +++ b/src/ui/identities/add_existing_identity_screen.rs @@ -55,9 +55,19 @@ struct TestnetNodes { hp_masternodes: std::collections::HashMap, } -fn load_testnet_nodes_from_yml(file_path: &str) -> Option { - let file_content = fs::read_to_string(file_path).ok()?; - serde_yaml_ng::from_str(&file_content).expect("expected proper yaml") +fn load_testnet_nodes_from_yml(file_path: &str) -> Result, String> { + let file_content = match fs::read_to_string(file_path) { + Ok(content) => content, + Err(_) => return Ok(None), + }; + serde_yaml_ng::from_str::(&file_content) + .map(Some) + .map_err(|e| { + format!( + "Failed to parse YAML file '{}': {}. Please check the file format.", + file_path, e + ) + }) } #[derive(Clone, Copy, PartialEq, Eq)] @@ -110,10 +120,13 @@ pub struct AddExistingIdentityScreen { impl AddExistingIdentityScreen { pub fn new(app_context: &Arc) -> Self { let selected_wallet = app_context.wallets.read().unwrap().values().next().cloned(); - let testnet_loaded_nodes = if app_context.network == Network::Testnet { - load_testnet_nodes_from_yml(".testnet_nodes.yml") + let (testnet_loaded_nodes, error_message) = if app_context.network == Network::Testnet { + match load_testnet_nodes_from_yml(".testnet_nodes.yml") { + Ok(nodes) => (nodes, None), + Err(e) => (None, Some(e)), + } } else { - None + (None, None) }; Self { identity_id_input: String::new(), @@ -128,7 +141,7 @@ impl AddExistingIdentityScreen { selected_wallet, identity_associated_with_wallet: true, wallet_unlock_popup: WalletUnlockPopup::new(), - error_message: None, + error_message, identity_index_input: String::new(), app_context: app_context.clone(), show_pop_up_info: None, @@ -873,10 +886,7 @@ impl AddExistingIdentityScreen { if let Some((name, hpmn)) = self .testnet_loaded_nodes .as_ref() - .unwrap() - .hp_masternodes - .iter() - .choose(&mut thread_rng()) + .and_then(|nodes| nodes.hp_masternodes.iter().choose(&mut thread_rng())) { self.identity_id_input = hpmn.protx_tx_hash.clone(); self.identity_type = IdentityType::Evonode; @@ -891,10 +901,7 @@ impl AddExistingIdentityScreen { if let Some((name, masternode)) = self .testnet_loaded_nodes .as_ref() - .unwrap() - .masternodes - .iter() - .choose(&mut thread_rng()) + .and_then(|nodes| nodes.masternodes.iter().choose(&mut thread_rng())) { self.identity_id_input = masternode.pro_tx_hash.clone(); self.identity_type = IdentityType::Masternode; From 4d8b2b2c1e477f8f3e9b52da52a353fcdfda60ee Mon Sep 17 00:00:00 2001 From: lklimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Feb 2026 09:50:36 +0100 Subject: [PATCH 005/147] chore: let Claude write manual test scenarios for PRs (#634) * chore: move doc/ contents into docs/ and update references Consolidate documentation under a single docs/ directory. Co-Authored-By: Claude Opus 4.6 * chore: CLAUDE.md should write manual test scenarios for PRs --------- Co-authored-by: Claude Opus 4.6 --- CLAUDE.md | 14 ++++++++++++-- {doc => docs}/COMPONENT_DESIGN_PATTERN.md | 0 src/ui/components/component_trait.rs | 2 +- 3 files changed, 13 insertions(+), 3 deletions(-) rename {doc => docs}/COMPONENT_DESIGN_PATTERN.md (100%) diff --git a/CLAUDE.md b/CLAUDE.md index 45b392c84..b8b7b30eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,6 +28,7 @@ cargo test --test kittest --all-features # UI integration tests (egui_ cargo test --test e2e --all-features # End-to-end tests ``` + Test locations: - Unit tests: inline in source files (`#[test]`) - UI integration: `tests/kittest/` @@ -35,6 +36,14 @@ Test locations: Always run `cargo clippy` and `cargo +nightly fmt` when finalizing your work. + +### Manual test scenarios + +You MUST identify manual tests needed for the changes and write a manual test scenarios. Use the `claudius:qa-engineer` agent if available. +Skip the manual test file only for non-functional changes (CI, docs, formatting, pure refactoring) — state why in the PR description. +Put tests in docs directory, as described in "Documentation" section below. Reference the file in the PR description under "Test plan". +Before creating a PR, re-review test scenarios and update them if needed. + ## CI: Safe Cargo Wrapper In GitHub Actions (Claude Code workflow), use `scripts/safe-cargo.sh` instead of `cargo` directly. This wrapper strips CI secrets from the environment before running cargo, preventing build scripts from accessing credentials. @@ -46,13 +55,14 @@ scripts/safe-cargo.sh clippy --all-features --all-targets -- -D warnings scripts/safe-cargo.sh +nightly fmt --all ``` + ## Architecture Overview **Dash Evo Tool** is a cross-platform GUI application (Rust + egui) for interacting with Dash Evolution. It enables DPNS username registration, contest voting, state transition viewing, wallet management, and identity operations across Mainnet/Testnet/Devnet. ## Documentation -- **docs/ai-design** should contain architecture and technical design files, grouped in subdirectories prefixed with ISO-formatted date +- **docs/ai-design** should contain architecture, technical design and manual testing scenarios files, grouped in subdirectories prefixed with ISO-formatted date - end-user documentation is in a separate repo: https://github.com/dashpay/docs/tree/HEAD/docs/user/network/dash-evo-tool , published at https://docs.dash.org/en/stable/docs/user/network/dash-evo-tool/ ### Core Module Structure @@ -129,7 +139,7 @@ Screens hold `Arc` and manage their own UI state. ## UI Component Pattern -Components follow a lazy initialization pattern (see `doc/COMPONENT_DESIGN_PATTERN.md`): +Components follow a lazy initialization pattern (see `docs/COMPONENT_DESIGN_PATTERN.md`): ```rust struct MyScreen { diff --git a/doc/COMPONENT_DESIGN_PATTERN.md b/docs/COMPONENT_DESIGN_PATTERN.md similarity index 100% rename from doc/COMPONENT_DESIGN_PATTERN.md rename to docs/COMPONENT_DESIGN_PATTERN.md diff --git a/src/ui/components/component_trait.rs b/src/ui/components/component_trait.rs index 6a5feb28e..d4a3aa6e1 100644 --- a/src/ui/components/component_trait.rs +++ b/src/ui/components/component_trait.rs @@ -69,7 +69,7 @@ pub trait ComponentResponse: Clone { /// /// # See also /// -/// See `doc/COMPONENT_DESIGN_PATTERN.md` for detailed design pattern documentation. +/// See `docs/COMPONENT_DESIGN_PATTERN.md` for detailed design pattern documentation. pub trait Component { /// The domain object type that this component is designed to handle. /// This type represents the data this component is designed to handle, From 17650e921318021dd8b759e08d5e272b8f1d26bd Mon Sep 17 00:00:00 2001 From: lklimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Feb 2026 10:00:41 +0100 Subject: [PATCH 006/147] build(flatpak): use only-arches for dynamic protoc architecture selection (#603) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * build(flatpak): use only-arches for dynamic protoc architecture selection Replace the fragile sed-based CI patching of the Flatpak manifest with Flatpak's native `only-arches` source selector. The protoc module now declares both x86_64 and aarch64 sources inline, and build-commands use a glob pattern (`protoc-*.zip`) so no per-arch fixup is needed. Changes: - flatpak manifest: add aarch64 protoc source with `only-arches`, use glob in unzip commands, remove stale CI-patching comment - CI workflow: remove `protoc-zip`/`protoc-sha256` matrix variables and the "Patch manifest for architecture" step https://claude.ai/code/session_015AD2pCWoJdV2VDydcqFHPG * fix(flatpak): use dest-filename for deterministic protoc extraction Use dest-filename to normalize both arch-specific protoc zips to a common name, avoiding glob expansion in build-commands. This ensures the unzip target is deterministic regardless of shell behavior in the Flatpak sandbox. https://claude.ai/code/session_015AD2pCWoJdV2VDydcqFHPG * build: update platform to b445b6f0 and remove rust-dashcore patches Update dashpay/platform dependency from d6f4eb9a to b445b6f0e0bd4863 (3.0.1 → 3.1.0-dev.1). Remove the [patch] section that pinned rust-dashcore crates to a separate rev, as the new platform commit resolves them correctly on its own. https://claude.ai/code/session_015AD2pCWoJdV2VDydcqFHPG --------- Co-authored-by: Claude --- .github/workflows/flatpak.yml | 9 --------- flatpak/org.dash.DashEvoTool.yml | 14 +++++++++++--- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/.github/workflows/flatpak.yml b/.github/workflows/flatpak.yml index c082bd4d9..51907d9bb 100644 --- a/.github/workflows/flatpak.yml +++ b/.github/workflows/flatpak.yml @@ -26,12 +26,8 @@ jobs: include: - arch: x86_64 runs-on: ubuntu-latest - protoc-zip: protoc-25.2-linux-x86_64.zip - protoc-sha256: 78ab9c3288919bdaa6cfcec6127a04813cf8a0ce406afa625e48e816abee2878 - arch: aarch64 runs-on: ubuntu-24.04-arm - protoc-zip: protoc-25.2-linux-aarch_64.zip - protoc-sha256: 07683afc764e4efa3fa969d5f049fbc2bdfc6b4e7786a0b233413ac0d8753f6b runs-on: ${{ matrix.runs-on }} timeout-minutes: 20 @@ -59,11 +55,6 @@ jobs: echo "=== Disk space after SDK install ===" df -h - - name: Patch manifest for architecture - run: | - sed -i "s|protoc-25.2-linux-x86_64.zip|${{ matrix.protoc-zip }}|g" flatpak/org.dash.DashEvoTool.yml - sed -i "s|sha256: 78ab9c3288919bdaa6cfcec6127a04813cf8a0ce406afa625e48e816abee2878|sha256: ${{ matrix.protoc-sha256 }}|" flatpak/org.dash.DashEvoTool.yml - - name: Prepare Cargo cache run: mkdir -p cargo-cache/registry cargo-cache/git cargo-target diff --git a/flatpak/org.dash.DashEvoTool.yml b/flatpak/org.dash.DashEvoTool.yml index 10583c21a..12ecb4ea6 100644 --- a/flatpak/org.dash.DashEvoTool.yml +++ b/flatpak/org.dash.DashEvoTool.yml @@ -35,16 +35,24 @@ build-options: modules: # Module 1: Install protobuf compiler (required for building dash-sdk) - # NOTE: CI patches this module for aarch64 via sed (see .github/workflows/flatpak.yml). - name: protoc buildsystem: simple sources: - type: file + only-arches: + - x86_64 + dest-filename: protoc.zip url: https://github.com/protocolbuffers/protobuf/releases/download/v25.2/protoc-25.2-linux-x86_64.zip sha256: 78ab9c3288919bdaa6cfcec6127a04813cf8a0ce406afa625e48e816abee2878 + - type: file + only-arches: + - aarch64 + dest-filename: protoc.zip + url: https://github.com/protocolbuffers/protobuf/releases/download/v25.2/protoc-25.2-linux-aarch_64.zip + sha256: 07683afc764e4efa3fa969d5f049fbc2bdfc6b4e7786a0b233413ac0d8753f6b build-commands: - - unzip protoc-25.2-linux-x86_64.zip bin/protoc -d /app - - unzip protoc-25.2-linux-x86_64.zip 'include/*' -d /app + - unzip protoc.zip bin/protoc -d /app + - unzip protoc.zip 'include/*' -d /app - chmod 755 /app/bin/protoc # Module 2: Build libsodium (required for crypto operations) From 1ec2e7e3e9a99ed40c728416068d0777f0a45f08 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Feb 2026 23:56:12 +0100 Subject: [PATCH 007/147] fix(ci): remove local path patches, use git deps for platform crates The [patch] section referenced ../platform local paths that don't exist in CI. Since dash-sdk already depends on the feat/zk branch, all transitive platform crates resolve correctly without patches. Also fixes a formatting issue in address_table.rs. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 22 ++++++++++++++++++ Cargo.toml | 23 ------------------- .../wallets/wallets_screen/address_table.rs | 1 - 3 files changed, 22 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3a62bf3dd..6a0979ea6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1773,6 +1773,7 @@ dependencies = [ [[package]] name = "dapi-grpc" version = "3.1.0-dev.1" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fzk#4d7b9be50a3d07c56d72a2721a8307cf2713c833" dependencies = [ "dash-platform-macros", "futures-core", @@ -1840,6 +1841,7 @@ dependencies = [ [[package]] name = "dash-context-provider" version = "3.1.0-dev.1" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fzk#4d7b9be50a3d07c56d72a2721a8307cf2713c833" dependencies = [ "dpp", "drive", @@ -1929,6 +1931,7 @@ dependencies = [ [[package]] name = "dash-platform-macros" version = "3.1.0-dev.1" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fzk#4d7b9be50a3d07c56d72a2721a8307cf2713c833" dependencies = [ "heck", "quote", @@ -1938,6 +1941,7 @@ dependencies = [ [[package]] name = "dash-sdk" version = "3.1.0-dev.1" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fzk#4d7b9be50a3d07c56d72a2721a8307cf2713c833" dependencies = [ "arc-swap", "async-trait", @@ -2089,6 +2093,7 @@ dependencies = [ [[package]] name = "dashpay-contract" version = "3.1.0-dev.1" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fzk#4d7b9be50a3d07c56d72a2721a8307cf2713c833" dependencies = [ "platform-value", "platform-version", @@ -2099,6 +2104,7 @@ dependencies = [ [[package]] name = "data-contracts" version = "3.1.0-dev.1" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fzk#4d7b9be50a3d07c56d72a2721a8307cf2713c833" dependencies = [ "dashpay-contract", "dpns-contract", @@ -2351,6 +2357,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "dpns-contract" version = "3.1.0-dev.1" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fzk#4d7b9be50a3d07c56d72a2721a8307cf2713c833" dependencies = [ "platform-value", "platform-version", @@ -2361,6 +2368,7 @@ dependencies = [ [[package]] name = "dpp" version = "3.1.0-dev.1" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fzk#4d7b9be50a3d07c56d72a2721a8307cf2713c833" dependencies = [ "anyhow", "async-trait", @@ -2409,6 +2417,7 @@ dependencies = [ [[package]] name = "drive" version = "3.1.0-dev.1" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fzk#4d7b9be50a3d07c56d72a2721a8307cf2713c833" dependencies = [ "bincode 2.0.1", "byteorder", @@ -2433,6 +2442,7 @@ dependencies = [ [[package]] name = "drive-proof-verifier" version = "3.1.0-dev.1" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fzk#4d7b9be50a3d07c56d72a2721a8307cf2713c833" dependencies = [ "bincode 2.0.1", "dapi-grpc", @@ -3014,6 +3024,7 @@ dependencies = [ [[package]] name = "feature-flags-contract" version = "3.1.0-dev.1" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fzk#4d7b9be50a3d07c56d72a2721a8307cf2713c833" dependencies = [ "platform-value", "platform-version", @@ -4758,6 +4769,7 @@ dependencies = [ [[package]] name = "keyword-search-contract" version = "3.1.0-dev.1" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fzk#4d7b9be50a3d07c56d72a2721a8307cf2713c833" dependencies = [ "platform-value", "platform-version", @@ -4953,6 +4965,7 @@ dependencies = [ [[package]] name = "masternode-reward-shares-contract" version = "3.1.0-dev.1" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fzk#4d7b9be50a3d07c56d72a2721a8307cf2713c833" dependencies = [ "platform-value", "platform-version", @@ -6082,6 +6095,7 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "platform-serialization" version = "3.1.0-dev.1" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fzk#4d7b9be50a3d07c56d72a2721a8307cf2713c833" dependencies = [ "bincode 2.0.1", "platform-version", @@ -6090,6 +6104,7 @@ dependencies = [ [[package]] name = "platform-serialization-derive" version = "3.1.0-dev.1" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fzk#4d7b9be50a3d07c56d72a2721a8307cf2713c833" dependencies = [ "proc-macro2", "quote", @@ -6100,6 +6115,7 @@ dependencies = [ [[package]] name = "platform-value" version = "3.1.0-dev.1" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fzk#4d7b9be50a3d07c56d72a2721a8307cf2713c833" dependencies = [ "base64 0.22.1", "bincode 2.0.1", @@ -6119,6 +6135,7 @@ dependencies = [ [[package]] name = "platform-version" version = "3.1.0-dev.1" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fzk#4d7b9be50a3d07c56d72a2721a8307cf2713c833" dependencies = [ "bincode 2.0.1", "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=56120fb5d0f8abfa9038e018115cdb11c51cde18)", @@ -6129,6 +6146,7 @@ dependencies = [ [[package]] name = "platform-versioning" version = "3.1.0-dev.1" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fzk#4d7b9be50a3d07c56d72a2721a8307cf2713c833" dependencies = [ "proc-macro2", "quote", @@ -6888,6 +6906,7 @@ dependencies = [ [[package]] name = "rs-dapi-client" version = "3.1.0-dev.1" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fzk#4d7b9be50a3d07c56d72a2721a8307cf2713c833" dependencies = [ "backon", "chrono", @@ -8077,6 +8096,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "token-history-contract" version = "3.1.0-dev.1" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fzk#4d7b9be50a3d07c56d72a2721a8307cf2713c833" dependencies = [ "platform-value", "platform-version", @@ -8847,6 +8867,7 @@ dependencies = [ [[package]] name = "wallet-utils-contract" version = "3.1.0-dev.1" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fzk#4d7b9be50a3d07c56d72a2721a8307cf2713c833" dependencies = [ "platform-value", "platform-version", @@ -10253,6 +10274,7 @@ dependencies = [ [[package]] name = "withdrawals-contract" version = "3.1.0-dev.1" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fzk#4d7b9be50a3d07c56d72a2721a8307cf2713c833" dependencies = [ "num_enum 0.5.11", "platform-value", diff --git a/Cargo.toml b/Cargo.toml index f244fa4da..34280f19d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -97,29 +97,6 @@ winres = "0.1" level = "warn" check-cfg = ["cfg(tokio_unstable)"] -[patch."https://github.com/dashpay/platform"] -dapi-grpc = { path = "../platform/packages/dapi-grpc" } -dash-context-provider = { path = "../platform/packages/rs-context-provider" } -dash-platform-macros = { path = "../platform/packages/rs-dash-platform-macros" } -dash-sdk = { path = "../platform/packages/rs-sdk" } -dashpay-contract = { path = "../platform/packages/dashpay-contract" } -data-contracts = { path = "../platform/packages/data-contracts" } -dpns-contract = { path = "../platform/packages/dpns-contract" } -dpp = { path = "../platform/packages/rs-dpp" } -drive = { path = "../platform/packages/rs-drive" } -drive-proof-verifier = { path = "../platform/packages/rs-drive-proof-verifier" } -feature-flags-contract = { path = "../platform/packages/feature-flags-contract" } -keyword-search-contract = { path = "../platform/packages/keyword-search-contract" } -masternode-reward-shares-contract = { path = "../platform/packages/masternode-reward-shares-contract" } -platform-serialization = { path = "../platform/packages/rs-platform-serialization" } -platform-serialization-derive = { path = "../platform/packages/rs-platform-serialization-derive" } -platform-value = { path = "../platform/packages/rs-platform-value" } -platform-version = { path = "../platform/packages/rs-platform-version" } -platform-versioning = { path = "../platform/packages/rs-platform-versioning" } -rs-dapi-client = { path = "../platform/packages/rs-dapi-client" } -token-history-contract = { path = "../platform/packages/token-history-contract" } -wallet-utils-contract = { path = "../platform/packages/wallet-utils-contract" } -withdrawals-contract = { path = "../platform/packages/withdrawals-contract" } [lints.clippy] uninlined_format_args = "allow" diff --git a/src/ui/wallets/wallets_screen/address_table.rs b/src/ui/wallets/wallets_screen/address_table.rs index dceb5d4b6..92d35573e 100644 --- a/src/ui/wallets/wallets_screen/address_table.rs +++ b/src/ui/wallets/wallets_screen/address_table.rs @@ -240,7 +240,6 @@ impl WalletsBalancesScreen { self.sort_order = SortOrder::Descending; } - // Render the table let mut builder = TableBuilder::new(ui) .id_salt("addresses_table") From 07102954326fa60adddf05e31193074d06fdfd1c Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 25 Feb 2026 01:03:55 +0100 Subject: [PATCH 008/147] fix(test): store wallet in DB before registering addresses The remove_utxos tests were failing because `register_test_address` inserts into `wallet_addresses` which has a foreign key constraint on `wallet(seed_hash)`. Added the missing `store_wallet` call so the parent row exists before inserting child address rows. Co-Authored-By: Claude Opus 4.6 --- src/model/wallet/mod.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/model/wallet/mod.rs b/src/model/wallet/mod.rs index 8bf6fc12c..9a54e0077 100644 --- a/src/model/wallet/mod.rs +++ b/src/model/wallet/mod.rs @@ -2794,6 +2794,8 @@ mod tests { assert_eq!(wallet.max_balance(), 300_000); let db = create_test_database().expect("test db"); + db.store_wallet(&wallet, &Network::Testnet) + .expect("store test wallet"); register_test_address(&db, &wallet, &addr); let (selected, _) = wallet .select_unspent_utxos_for(90_000, 10_000, false) @@ -2814,6 +2816,8 @@ mod tests { add_utxo(&mut wallet, &addr, 1, 0, 100_000); let db = create_test_database().expect("test db"); + db.store_wallet(&wallet, &Network::Testnet) + .expect("store test wallet"); register_test_address(&db, &wallet, &addr); let (selected, _) = wallet .select_unspent_utxos_for(90_000, 10_000, false) From afd7a4c79e2e3856bf7a7ec9c4ca4384ec40f27e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 25 Feb 2026 17:52:57 +0100 Subject: [PATCH 009/147] fix(build): restore shielded module declaration removed during merge The `pub mod shielded;` declaration was accidentally removed from `src/model/wallet/mod.rs` during the v1.0-dev merge, causing build failures since the shielded.rs file still exists. Co-Authored-By: Claude Opus 4.6 --- src/model/wallet/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/model/wallet/mod.rs b/src/model/wallet/mod.rs index 79ddaaadb..8591f3b0d 100644 --- a/src/model/wallet/mod.rs +++ b/src/model/wallet/mod.rs @@ -1,5 +1,6 @@ mod asset_lock_transaction; pub mod encryption; +pub mod shielded; pub mod single_key; mod utxos; From 64651c5022ba95037dd1c6b5bebefb9596dfccb5 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 18:55:47 +0100 Subject: [PATCH 010/147] fix(test): restore store_wallet calls lost in merge (#663) * Initial plan * fix(test): restore store_wallet calls lost in merge Co-authored-by: lklimek <842586+lklimek@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lklimek <842586+lklimek@users.noreply.github.com> --- src/model/wallet/mod.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/model/wallet/mod.rs b/src/model/wallet/mod.rs index 8591f3b0d..51b02adc2 100644 --- a/src/model/wallet/mod.rs +++ b/src/model/wallet/mod.rs @@ -2780,6 +2780,8 @@ mod tests { assert_eq!(wallet.max_balance(), 300_000); let db = create_test_database().expect("test db"); + db.store_wallet(&wallet, &Network::Testnet) + .expect("store test wallet"); register_test_address(&db, &wallet, &addr); let (selected, _) = wallet .select_unspent_utxos_for(90_000, 10_000, false) @@ -2800,6 +2802,8 @@ mod tests { add_utxo(&mut wallet, &addr, 1, 0, 100_000); let db = create_test_database().expect("test db"); + db.store_wallet(&wallet, &Network::Testnet) + .expect("store test wallet"); register_test_address(&db, &wallet, &addr); let (selected, _) = wallet .select_unspent_utxos_for(90_000, 10_000, false) From e2060b2e7a162a9ef2c804608a024619313bbd7d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:18:49 +0100 Subject: [PATCH 011/147] Merge remote-tracking branch 'origin/v1.0-dev' into zk-extract/all-merged Resolve conflicts in Cargo.toml (keep feat/zk branch), Cargo.lock (regenerate with pinned platform rev 4d7b9be5), and backend_task/mod.rs (combine TaskError wrapping with ShieldedTask). Fix post-merge integration issues: - SPV manager: remove stale .await on subscribe methods, add command_receiver channel for updated DashSpvClient::run() API - send_screen: update SendStatus::WaitingForResult to unit variant - network_chooser_screen: handle new SyncState::Initializing variant Co-Authored-By: Claude Opus 4.6 --- .coderabbit.yaml | 1 + .github/CODEOWNERS | 2 - .github/workflows/clippy.yml | 26 +- .github/workflows/release.yml | 28 +- .github/workflows/tests.yml | 26 +- CLAUDE.md | 25 +- Cargo.lock | 370 +++++++++--------- Cargo.toml | 8 +- .../manual-tests.md | 254 ++++++++++++ .../manual-test-scenarios.md | 105 +++++ .../pr-604-review-guide.md | 219 +++++++++++ .../manual-test-scenarios.md | 61 +++ .../manual-test.md | 24 ++ src/app.rs | 210 +++++++++- .../query_dpns_contested_resources.rs | 4 +- src/backend_task/core/mod.rs | 101 ++++- src/backend_task/core/recover_asset_locks.rs | 22 ++ .../core/send_single_key_wallet_payment.rs | 16 +- src/backend_task/core/start_dash_qt.rs | 1 - src/backend_task/error.rs | 45 +++ src/backend_task/mod.rs | 54 +-- .../wallet/fetch_platform_address_balances.rs | 13 +- src/context/connection_status.rs | 199 ++++++++-- src/context/mod.rs | 57 +-- src/context_provider.rs | 112 ++++-- src/context_provider_spv.rs | 64 ++- src/database/mod.rs | 1 + src/logging.rs | 49 ++- src/main.rs | 3 + src/spv/manager.rs | 272 ++++++++----- src/ui/components/message_banner.rs | 271 +++++++++++-- src/ui/components/mod.rs | 5 +- src/ui/components/top_panel.rs | 28 +- src/ui/components/wallet_unlock.rs | 57 +-- .../add_contracts_screen.rs | 89 ++--- .../contracts_documents_screen.rs | 132 +++---- .../document_action_screen.rs | 254 ++++++------ .../group_actions_screen.rs | 98 ++--- .../register_contract_screen.rs | 82 ++-- .../update_contract_screen.rs | 114 +++--- src/ui/dashpay/add_contact_screen.rs | 74 ++-- src/ui/dashpay/contact_details.rs | 32 +- src/ui/dashpay/contact_info_editor.rs | 40 +- src/ui/dashpay/contact_profile_viewer.rs | 43 +- src/ui/dashpay/contact_requests.rs | 112 ++---- src/ui/dashpay/contacts_list.rs | 58 +-- src/ui/dashpay/profile_screen.rs | 75 ++-- src/ui/dashpay/profile_search.rs | 43 +- src/ui/dashpay/qr_code_generator.rs | 74 ++-- src/ui/dashpay/qr_scanner.rs | 95 +++-- src/ui/dashpay/send_payment.rs | 94 ++--- src/ui/dpns/dpns_contested_names_screen.rs | 143 +++---- .../add_existing_identity_screen.rs | 206 ++++------ .../by_platform_address.rs | 4 +- .../by_using_unused_asset_lock.rs | 35 +- .../by_using_unused_balance.rs | 4 +- .../by_wallet_qr_code.rs | 7 +- .../identities/add_new_identity_screen/mod.rs | 41 +- src/ui/identities/identities_screen.rs | 26 +- src/ui/identities/keys/add_key_screen.rs | 195 ++++----- src/ui/identities/keys/key_info_screen.rs | 131 +++---- src/ui/identities/mod.rs | 57 +-- .../identities/register_dpns_name_screen.rs | 122 ++---- .../by_using_unused_asset_lock.rs | 28 +- .../identities/top_up_identity_screen/mod.rs | 10 +- src/ui/identities/transfer_screen.rs | 201 +++++----- src/ui/identities/withdraw_screen.rs | 160 +++----- src/ui/mod.rs | 17 +- src/ui/network_chooser_screen.rs | 100 ++--- src/ui/theme.rs | 9 + src/ui/tokens/add_token_by_id_screen.rs | 67 ++-- src/ui/tokens/burn_tokens_screen.rs | 120 +++--- src/ui/tokens/claim_tokens_screen.rs | 198 +++++----- src/ui/tokens/destroy_frozen_funds_screen.rs | 135 +++---- src/ui/tokens/direct_token_purchase_screen.rs | 129 +++--- src/ui/tokens/freeze_tokens_screen.rs | 144 +++---- src/ui/tokens/mint_tokens_screen.rs | 120 +++--- src/ui/tokens/mod.rs | 44 +++ src/ui/tokens/pause_tokens_screen.rs | 120 +++--- src/ui/tokens/resume_tokens_screen.rs | 120 +++--- src/ui/tokens/set_token_price_screen.rs | 136 ++++--- .../tokens/tokens_screen/contract_details.rs | 10 +- src/ui/tokens/tokens_screen/keyword_search.rs | 44 +-- src/ui/tokens/tokens_screen/mod.rs | 193 +++++---- src/ui/tokens/tokens_screen/my_tokens.rs | 101 ++--- src/ui/tokens/tokens_screen/structs.rs | 2 +- src/ui/tokens/tokens_screen/token_creator.rs | 107 +++-- src/ui/tokens/transfer_tokens_screen.rs | 344 +++++++++------- src/ui/tokens/unfreeze_tokens_screen.rs | 144 +++---- src/ui/tokens/update_token_config.rs | 204 ++++------ src/ui/tokens/view_token_claims_screen.rs | 34 +- src/ui/tools/address_balance_screen.rs | 40 +- src/ui/tools/contract_visualizer_screen.rs | 8 +- src/ui/tools/document_visualizer_screen.rs | 8 +- src/ui/tools/grovestark_screen.rs | 137 +++---- src/ui/tools/masternode_list_diff_screen.rs | 53 +-- src/ui/tools/platform_info_screen.rs | 38 +- src/ui/tools/transition_visualizer_screen.rs | 142 +++---- src/ui/wallets/asset_lock_detail_screen.rs | 60 +-- src/ui/wallets/create_asset_lock_screen.rs | 89 ++--- src/ui/wallets/send_screen.rs | 123 +++--- src/ui/wallets/single_key_send_screen.rs | 84 ++-- .../wallets/wallets_screen/address_table.rs | 11 +- src/ui/wallets/wallets_screen/dialogs.rs | 7 +- src/ui/wallets/wallets_screen/mod.rs | 197 +++++----- src/utils/tasks.rs | 200 ++++++---- 106 files changed, 5074 insertions(+), 4377 deletions(-) delete mode 100644 .github/CODEOWNERS create mode 100644 docs/ai-design/2025-02-25-spv-peer-rework/manual-tests.md create mode 100644 docs/ai-design/2026-02-24-spv-sync-error-status/manual-test-scenarios.md create mode 100644 docs/ai-design/2026-02-27-banner-review-fixes/pr-604-review-guide.md create mode 100644 docs/ai-design/2026-03-03-fix-nonce-reset-on-refresh/manual-test-scenarios.md create mode 100644 docs/ai-design/2026-03-05-banner-details-overlap/manual-test.md create mode 100644 src/backend_task/error.rs diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 93ce469db..35ceb899d 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -2,6 +2,7 @@ reviews: auto_review: enabled: true + drafts: false base_branches: - master - v1.0-dev diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index 195ba2b66..000000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1,2 +0,0 @@ -# Default code owners for all files -* @PastaPastaPasta @lklimek diff --git a/.github/workflows/clippy.yml b/.github/workflows/clippy.yml index c256e85b1..339b9a712 100644 --- a/.github/workflows/clippy.yml +++ b/.github/workflows/clippy.yml @@ -6,6 +6,7 @@ on: - main - "v*-dev" pull_request: + types: [opened, synchronize, reopened, ready_for_review] concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -17,33 +18,10 @@ env: jobs: clippy: name: Clippy + if: github.event.pull_request.draft != true runs-on: ubuntu-latest steps: - - name: Free disk space - run: | - echo "=== Disk space before cleanup ===" - df -h - # Remove large unnecessary directories - sudo rm -rf /usr/share/dotnet - sudo rm -rf /usr/local/lib/android - sudo rm -rf /opt/ghc - sudo rm -rf /opt/hostedtoolcache/CodeQL - sudo rm -rf /opt/hostedtoolcache/go - sudo rm -rf /opt/hostedtoolcache/node - sudo rm -rf /usr/local/share/boost - sudo rm -rf /usr/share/swift - sudo rm -rf /usr/local/graalvm - sudo rm -rf /usr/local/.ghcup - # Clean docker - sudo docker image prune --all --force || true - sudo docker system prune --all --force || true - # Clean apt cache - sudo apt-get clean - sudo rm -rf /var/lib/apt/lists/* - echo "=== Disk space after cleanup ===" - df -h - - name: Checkout code uses: actions/checkout@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d40e103c..837246109 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,31 +42,6 @@ jobs: runs-on: ${{ matrix.runs-on }} steps: - - name: Free disk space - if: ${{ runner.os == 'Linux' }} - run: | - echo "=== Disk space before cleanup ===" - df -h - # Remove large unnecessary directories - sudo rm -rf /usr/share/dotnet - sudo rm -rf /usr/local/lib/android - sudo rm -rf /opt/ghc - sudo rm -rf /opt/hostedtoolcache/CodeQL - sudo rm -rf /opt/hostedtoolcache/go - sudo rm -rf /opt/hostedtoolcache/node - sudo rm -rf /usr/local/share/boost - sudo rm -rf /usr/share/swift - sudo rm -rf /usr/local/graalvm - sudo rm -rf /usr/local/.ghcup - # Clean docker - sudo docker image prune --all --force || true - sudo docker system prune --all --force || true - # Clean apt cache - sudo apt-get clean - sudo rm -rf /var/lib/apt/lists/* - echo "=== Disk space after cleanup ===" - df -h - - name: Check out code uses: actions/checkout@v4 @@ -115,12 +90,13 @@ jobs: - name: Windows libsql if: ${{ matrix.target == 'x86_64-pc-windows-gnu' }} - run: curl -OL https://www.sqlite.org/2024/sqlite-dll-win-x64-3460100.zip && sudo unzip -o sqlite-dll-win-x64-3460100.zip -d winlibs && sudo chown -R runner:docker winlibs/ && pwd && ls -lah && cd winlibs && x86_64-w64-mingw32-dlltool -d sqlite3.def -l libsqlite3.a && ls -lah && cd .. + run: curl -OL https://www.sqlite.org/2024/sqlite-dll-win-x64-3460100.zip && sudo unzip -o sqlite-dll-win-x64-3460100.zip -d winlibs && sudo chown -R runner:docker winlibs/ && pwd && ls -lah && cd winlibs && x86_64-w64-mingw32-dlltool -d sqlite3.def -l libsqlite3.a && ls -lah && cd .. && sudo cp winlibs/libsqlite3.a /usr/x86_64-w64-mingw32/lib/libsqlite3.a - name: Build project run: | cargo build --release --target ${{ matrix.target }} mv target/${{ matrix.target }}/release/dash-evo-tool${{ matrix.ext }} dash-evo-tool/dash-evo-tool${{ matrix.ext }} + if [ -f winlibs/sqlite3.dll ]; then cp winlibs/sqlite3.dll dash-evo-tool/sqlite3.dll; fi - name: Package release run: | diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3bd7ea46e..e1d91196d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,6 +6,7 @@ on: - main - "v*-dev" pull_request: + types: [opened, synchronize, reopened, ready_for_review] concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -17,33 +18,10 @@ env: jobs: test: name: Test Suite + if: github.event.pull_request.draft != true runs-on: ubuntu-latest steps: - - name: Free disk space - run: | - echo "=== Disk space before cleanup ===" - df -h - # Remove large unnecessary directories - sudo rm -rf /usr/share/dotnet - sudo rm -rf /usr/local/lib/android - sudo rm -rf /opt/ghc - sudo rm -rf /opt/hostedtoolcache/CodeQL - sudo rm -rf /opt/hostedtoolcache/go - sudo rm -rf /opt/hostedtoolcache/node - sudo rm -rf /usr/local/share/boost - sudo rm -rf /usr/share/swift - sudo rm -rf /usr/local/graalvm - sudo rm -rf /usr/local/.ghcup - # Clean docker - sudo docker image prune --all --force || true - sudo docker system prune --all --force || true - # Clean apt cache - sudo apt-get clean - sudo rm -rf /var/lib/apt/lists/* - echo "=== Disk space after cleanup ===" - df -h - - name: Checkout code uses: actions/checkout@v4 diff --git a/CLAUDE.md b/CLAUDE.md index 2e18f3717..1bab7cc68 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,13 +58,10 @@ scripts/safe-cargo.sh +nightly fmt --all ## Coding Conventions -### Parameter ordering +### General rules -When a method takes `&AppContext` (or `Option<&AppContext>`), place it as the first parameter after `self`. Example: - -```rust -fn remove_selected_utxos(&mut self, context: Option<&AppContext>, selected: &BTreeMap<...>) -> Result<(), String> -``` +* When a method takes `&AppContext` (or `Option<&AppContext>`), place it as the first parameter after `self`. +* Screen constructors handle errors internally via `MessageBanner` and return `Self` with degraded state. Keep `create_screen()` clean — no error handling at callsites. ## Architecture Overview @@ -123,6 +120,8 @@ Screen::ui() → AppAction::BackendTask(task) **Backend task enums**: `BackendTask` has variants like `IdentityTask(IdentityTask)`, `WalletTask(WalletTask)`, `TokenTask(Box)`, etc. Each sub-enum has its own variants and corresponding `run_*_task()` method. Results are `BackendTaskSuccessResult` with 50+ typed variants. +**Error handling**: Backend tasks return `Result` (`src/backend_task/error.rs`). `TaskError` is a typed error envelope — `Display` produces user-friendly text for `MessageBanner`, `Debug` provides technical details for logs. `From` ensures backwards compatibility: existing `Result` code works unchanged. Domain errors (`DashPayError`, `SpvError`, etc.) are wired as `#[from]` variants for automatic conversion via `?`. When adding new backend error types, add a `#[from]` variant to `TaskError` rather than converting to `String`. + ## Screen Pattern All screens implement the `ScreenLike` trait: @@ -175,11 +174,21 @@ response.inner.update(&mut self.amount); ## Message Display -User-facing messages use `MessageBanner` (`src/ui/components/message_banner.rs`). Global banners are rendered centrally by `island_central_panel()` — `AppState::update()` sets them automatically for backend task results. Screens only override `display_message()` for side-effects. See the component's doc comments and `docs/ai-design/2026-02-17-unified-messages/` for details. +User-facing messages (errors, warnings, success, infos) use `MessageBanner` (`src/ui/components/message_banner.rs`). Global banners are rendered centrally by `island_central_panel()` — `AppState::update()` sets them automatically for backend task results. When using `MessageBanner::set_global()`, no guard is needed — it is idempotent and automatically logs at the appropriate level (error/warn/debug). Screens only override `display_message()` for side-effects. See the component's doc comments and `docs/ai-design/2026-02-17-unified-messages/` for details. + +**BannerHandle lifecycle**: Screens that run backend tasks typically store a `refresh_banner: Option` field. On task dispatch, set it via `MessageBanner::set_global()` with an info/progress message. In `display_message()` (called as a side-effect by AppState), dismiss the progress banner via `self.refresh_banner.take_and_clear()` (from `OptionBannerExt`). Simply setting the field to `None` would leak the banner — `take_and_clear()` removes it from the egui context. AppState handles displaying the actual result banner. + +**Logging**: MessageBanner logs all displayed messages (with details) automatically. Additional logging is unnecessary. + +**Error banners**: Never expose raw backend/database errors to users. Use a generic user-friendly message in the banner and attach technical details via `BannerHandle::with_details()`. When the error implements `Display` and its text is user-appropriate, pass it directly to `set_global`; otherwise use a descriptive generic message: +```rust +MessageBanner::set_global(ctx, "Failed to load token balances", MessageType::Error) + .with_details(e); +``` ## Database -Single SQLite connection wrapped in `Mutex`. Schema initialized in `database/initialization.rs`. Domain modules provide typed CRUD methods. Backend task errors are `Result` — string errors display directly to users. +Single SQLite connection wrapped in `Mutex`. Schema initialized in `database/initialization.rs`. Domain modules provide typed CRUD methods. Backend task errors use `TaskError` (`src/backend_task/error.rs`) — see App Task System section above. ## Platform Targets diff --git a/Cargo.lock b/Cargo.lock index 6a0979ea6..c7ae1ca15 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -293,7 +293,7 @@ dependencies = [ "clipboard-win", "image", "log", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", @@ -528,7 +528,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.3", + "rustix 1.1.4", "slab", "windows-sys 0.61.2", ] @@ -570,7 +570,7 @@ dependencies = [ "cfg-if", "event-listener 5.4.1", "futures-lite", - "rustix 1.1.3", + "rustix 1.1.4", ] [[package]] @@ -596,7 +596,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.3", + "rustix 1.1.4", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -722,9 +722,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.16.0" +version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9" +checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" dependencies = [ "aws-lc-sys", "zeroize", @@ -732,9 +732,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.37.1" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549" +checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" dependencies = [ "cc", "cmake", @@ -1035,7 +1035,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2 0.6.3", + "objc2 0.6.4", ] [[package]] @@ -1195,7 +1195,7 @@ checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" dependencies = [ "bitflags 2.11.0", "polling", - "rustix 1.1.3", + "rustix 1.1.4", "slab", "tracing", ] @@ -1219,7 +1219,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" dependencies = [ "calloop 0.14.4", - "rustix 1.1.3", + "rustix 1.1.4", "wayland-backend", "wayland-client", ] @@ -1908,7 +1908,7 @@ dependencies = [ "tracing", "tracing-subscriber", "tz-rs", - "which 8.0.0", + "which 8.0.1", "winres", "zeroize", "zeromq", @@ -2297,14 +2297,14 @@ checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" [[package]] name = "dispatch2" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.11.0", "block2 0.6.2", "libc", - "objc2 0.6.3", + "objc2 0.6.4", ] [[package]] @@ -2320,9 +2320,9 @@ dependencies = [ [[package]] name = "dlib" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ "libloading", ] @@ -2550,6 +2550,7 @@ dependencies = [ "objc2-foundation 0.2.2", "parking_lot", "percent-encoding", + "pollster", "profiling", "raw-window-handle", "ron", @@ -2559,6 +2560,7 @@ dependencies = [ "wasm-bindgen-futures", "web-sys", "web-time", + "wgpu", "windows-sys 0.61.2", "winit", ] @@ -3331,7 +3333,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix 1.1.3", + "rustix 1.1.4", "windows-link 0.2.1", ] @@ -3357,20 +3359,20 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -3462,7 +3464,7 @@ dependencies = [ "glutin_glx_sys", "glutin_wgl_sys", "libloading", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -4331,7 +4333,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.58.0", ] [[package]] @@ -4560,9 +4562,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.11.0" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iri-string" @@ -4615,9 +4617,9 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jiff" -version = "0.2.21" +version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3e3d65f018c6ae946ab16e80944b97096ed73c35b221d1c478a6c81d8f57940" +checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" dependencies = [ "jiff-static", "log", @@ -4628,9 +4630,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.21" +version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a17c2b211d863c7fde02cbea8a3c1a439b98e109286554f2860bdded7ff83818" +checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" dependencies = [ "proc-macro2", "quote", @@ -4671,9 +4673,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.89" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4eacb0641a310445a4c513f2a5e23e19952e269c6a38887254d5f837a305506" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" dependencies = [ "once_cell", "wasm-bindgen", @@ -4854,9 +4856,9 @@ checksum = "744a4c881f502e98c2241d2e5f50040ac73b30194d64452bb6260393b53f0dc9" [[package]] name = "libc" -version = "0.2.180" +version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" [[package]] name = "libloading" @@ -4876,13 +4878,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.12" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ "bitflags 2.11.0", "libc", - "redox_syscall 0.7.1", + "plain", + "redox_syscall 0.7.3", ] [[package]] @@ -4904,9 +4907,9 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" @@ -5096,9 +5099,9 @@ dependencies = [ [[package]] name = "moka" -version = "0.12.13" +version = "0.12.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac832c50ced444ef6be0767a008b02c106a909ba79d1d830501e94b96f6b7e" +checksum = "85f8024e1c8e71c778968af91d43700ce1d11b219d127d79fb2934153b82b42b" dependencies = [ "crossbeam-channel", "crossbeam-epoch", @@ -5184,7 +5187,7 @@ dependencies = [ "dirs", "dispatch2", "formatx", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", @@ -5246,9 +5249,9 @@ dependencies = [ [[package]] name = "nix" -version = "0.31.1" +version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225e7cfe711e0ba79a68baeddb2982723e4235247aefce1482f2f16c27865b66" +checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" dependencies = [ "bitflags 2.11.0", "cfg-if", @@ -5439,7 +5442,7 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.117", @@ -5472,9 +5475,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", ] @@ -5503,7 +5506,7 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.11.0", "block2 0.6.2", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -5553,7 +5556,7 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.11.0", "dispatch2", - "objc2 0.6.3", + "objc2 0.6.4", ] [[package]] @@ -5564,7 +5567,7 @@ checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.11.0", "dispatch2", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-core-foundation", "objc2-io-surface", ] @@ -5619,7 +5622,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.11.0", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-core-foundation", ] @@ -5630,7 +5633,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ "bitflags 2.11.0", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-core-foundation", ] @@ -6035,18 +6038,18 @@ checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" [[package]] name = "pin-project" -version = "1.1.10" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.10" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", @@ -6055,9 +6058,9 @@ dependencies = [ [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pin-utils" @@ -6067,9 +6070,9 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "piper" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" dependencies = [ "atomic-waker", "fastrand", @@ -6092,6 +6095,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "platform-serialization" version = "3.1.0-dev.1" @@ -6189,7 +6198,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.3", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -6289,11 +6298,11 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.23.10+spec-1.0.0", + "toml_edit 0.25.4+spec-1.1.0", ] [[package]] @@ -6350,7 +6359,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", @@ -6371,7 +6380,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", @@ -6408,12 +6417,9 @@ dependencies = [ [[package]] name = "pxfm" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" -dependencies = [ - "num-traits", -] +checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" [[package]] name = "qrcode" @@ -6440,6 +6446,15 @@ dependencies = [ "serde", ] +[[package]] +name = "quick-xml" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.9" @@ -6498,9 +6513,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.44" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -6511,6 +6526,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "radium" version = "0.7.0" @@ -6587,9 +6608,9 @@ dependencies = [ [[package]] name = "range-alloc" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d6831663a5098ea164f89cff59c6284e95f4e3c76ce9848d4529f5ccca9bde" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" [[package]] name = "raw-cpuid" @@ -6664,9 +6685,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.7.1" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" dependencies = [ "bitflags 2.11.0", ] @@ -6707,9 +6728,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.9" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "renderdoc-sys" @@ -6836,7 +6857,7 @@ dependencies = [ "js-sys", "libc", "log", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -6854,9 +6875,9 @@ dependencies = [ [[package]] name = "rgb" -version = "0.8.52" +version = "0.8.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" dependencies = [ "bytemuck", ] @@ -7035,22 +7056,22 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags 2.11.0", "errno", "libc", - "linux-raw-sys 0.11.0", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.36" +version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ "aws-lc-rs", "log", @@ -7564,7 +7585,7 @@ dependencies = [ "libc", "log", "memmap2", - "rustix 1.1.3", + "rustix 1.1.4", "thiserror 2.0.18", "wayland-backend", "wayland-client", @@ -7872,14 +7893,14 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tempfile" -version = "3.25.0" +version = "3.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" dependencies = [ "fastrand", - "getrandom 0.4.1", + "getrandom 0.4.2", "once_cell", - "rustix 1.1.3", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -8106,9 +8127,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.49.0" +version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ "bytes", "libc", @@ -8123,9 +8144,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", @@ -8210,9 +8231,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.5+spec-1.1.0" +version = "1.0.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" dependencies = [ "serde_core", ] @@ -8238,19 +8259,19 @@ dependencies = [ "serde", "serde_spanned", "toml_datetime 0.6.11", - "winnow 0.7.14", + "winnow 0.7.15", ] [[package]] name = "toml_edit" -version = "0.23.10+spec-1.0.0" +version = "0.25.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" dependencies = [ "indexmap 2.13.0", - "toml_datetime 0.7.5+spec-1.1.0", + "toml_datetime 1.0.0+spec-1.1.0", "toml_parser", - "winnow 0.7.14", + "winnow 0.7.15", ] [[package]] @@ -8259,7 +8280,7 @@ version = "1.0.9+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" dependencies = [ - "winnow 0.7.14", + "winnow 0.7.15", ] [[package]] @@ -8530,13 +8551,13 @@ checksum = "4fc6c929ffa10fb34f4a3c7e9a73620a83ef2e85e47f9ec3381b8289e6762f42" [[package]] name = "uds_windows" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +checksum = "51b70b87d15e91f553711b40df3048faf27a7a04e01e0ddc0cf9309f0af7c2ca" dependencies = [ "memoffset", "tempfile", - "winapi", + "windows-sys 0.61.2", ] [[package]] @@ -8752,11 +8773,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.21.0" +version = "1.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" dependencies = [ - "getrandom 0.4.1", + "getrandom 0.4.2", "js-sys", "serde_core", "wasm-bindgen", @@ -8910,9 +8931,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.112" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05d7d0fce354c88b7982aec4400b3e7fcf723c32737cef571bd165f7613557ee" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" dependencies = [ "cfg-if", "once_cell", @@ -8923,9 +8944,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.62" +version = "0.4.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee85afca410ac4abba5b584b12e77ea225db6ee5471d0aebaae0861166f9378a" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" dependencies = [ "cfg-if", "futures-util", @@ -8937,9 +8958,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.112" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55839b71ba921e4f75b674cb16f843f4b1f3b26ddfcb3454de1cf65cc021ec0f" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -8947,9 +8968,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.112" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caf2e969c2d60ff52e7e98b7392ff1588bffdd1ccd4769eba27222fd3d621571" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" dependencies = [ "bumpalo", "proc-macro2", @@ -8960,9 +8981,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.112" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0861f0dcdf46ea819407495634953cdcc8a8c7215ab799a7a7ce366be71c7b30" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" dependencies = [ "unicode-ident", ] @@ -9029,13 +9050,13 @@ dependencies = [ [[package]] name = "wayland-backend" -version = "0.3.12" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9" +checksum = "aa75f400b7f719bcd68b3f47cd939ba654cedeef690f486db71331eec4c6a406" dependencies = [ "cc", "downcast-rs", - "rustix 1.1.3", + "rustix 1.1.4", "scoped-tls", "smallvec", "wayland-sys", @@ -9043,12 +9064,12 @@ dependencies = [ [[package]] name = "wayland-client" -version = "0.31.12" +version = "0.31.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec" +checksum = "ab51d9f7c071abeee76007e2b742499e535148035bb835f97aaed1338cf516c3" dependencies = [ "bitflags 2.11.0", - "rustix 1.1.3", + "rustix 1.1.4", "wayland-backend", "wayland-scanner", ] @@ -9066,20 +9087,20 @@ dependencies = [ [[package]] name = "wayland-cursor" -version = "0.31.12" +version = "0.31.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5864c4b5b6064b06b1e8b74ead4a98a6c45a285fe7a0e784d24735f011fdb078" +checksum = "4b3298683470fbdc6ca40151dfc48c8f2fd4c41a26e13042f801f85002384091" dependencies = [ - "rustix 1.1.3", + "rustix 1.1.4", "wayland-client", "xcursor", ] [[package]] name = "wayland-protocols" -version = "0.32.10" +version = "0.32.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3" +checksum = "b23b5df31ceff1328f06ac607591d5ba360cf58f90c8fad4ac8d3a55a3c4aec7" dependencies = [ "bitflags 2.11.0", "wayland-backend", @@ -9102,9 +9123,9 @@ dependencies = [ [[package]] name = "wayland-protocols-misc" -version = "0.3.10" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "791c58fdeec5406aa37169dd815327d1e47f334219b523444bc26d70ceb4c34e" +checksum = "429b99200febaf95d4f4e46deff6fe4382bcff3280ee16a41cf887b3c3364984" dependencies = [ "bitflags 2.11.0", "wayland-backend", @@ -9115,9 +9136,9 @@ dependencies = [ [[package]] name = "wayland-protocols-plasma" -version = "0.3.10" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa98634619300a535a9a97f338aed9a5ff1e01a461943e8346ff4ae26007306b" +checksum = "d392fc283a87774afc9beefcd6f931582bb97fe0e6ced0b306a62cb1d026527c" dependencies = [ "bitflags 2.11.0", "wayland-backend", @@ -9128,9 +9149,9 @@ dependencies = [ [[package]] name = "wayland-protocols-wlr" -version = "0.3.10" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3" +checksum = "78248e4cc0eff8163370ba5c158630dcae1f3497a586b826eca2ef5f348d6235" dependencies = [ "bitflags 2.11.0", "wayland-backend", @@ -9141,20 +9162,20 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.8" +version = "0.31.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3" +checksum = "c86287151a309799b821ca709b7345a048a2956af05957c89cb824ab919fa4e3" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.39.2", "quote", ] [[package]] name = "wayland-sys" -version = "0.31.8" +version = "0.31.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd" +checksum = "374f6b70e8e0d6bf9461a32988fd553b59ff630964924dad6e4a4eb6bd538d17" dependencies = [ "dlib", "log", @@ -9164,9 +9185,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.89" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10053fbf9a374174094915bbce141e87a6bf32ecd9a002980db4b638405e8962" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" dependencies = [ "js-sys", "wasm-bindgen", @@ -9192,7 +9213,7 @@ dependencies = [ "jni", "log", "ndk-context", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-foundation 0.3.2", "url", "web-sys", @@ -9403,18 +9424,18 @@ checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" dependencies = [ "either", "env_home", - "rustix 1.1.3", + "rustix 1.1.4", "winsafe", ] [[package]] name = "which" -version = "8.0.0" +version = "8.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d" +checksum = "3a824aeba0fbb27264f815ada4cff43d65b1741b7a4ed7629ff9089148c4a4e0" dependencies = [ "env_home", - "rustix 1.1.3", + "rustix 1.1.4", "winsafe", ] @@ -9513,19 +9534,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" @@ -9977,9 +9985,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winit" -version = "0.30.12" +version = "0.30.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66d4b9ed69c4009f6321f762d6e61ad8a2389cd431b97cb1e146812e9e6c732" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" dependencies = [ "ahash", "android-activity", @@ -10038,9 +10046,9 @@ dependencies = [ [[package]] name = "winnow" -version = "0.7.14" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" dependencies = [ "memchr", ] @@ -10322,7 +10330,7 @@ dependencies = [ "libc", "libloading", "once_cell", - "rustix 1.1.3", + "rustix 1.1.4", "x11rb-protocol", ] @@ -10414,14 +10422,14 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix 1.1.3", + "rustix 1.1.4", "serde", "serde_repr", "tracing", "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow 0.7.14", + "winnow 0.7.15", "zbus_macros", "zbus_names", "zvariant", @@ -10457,7 +10465,7 @@ version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.117", @@ -10473,7 +10481,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" dependencies = [ "serde", - "winnow 0.7.14", + "winnow 0.7.15", "zvariant", ] @@ -10483,7 +10491,7 @@ version = "5.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "441a0064125265655bccc3a6af6bef56814d9277ac83fce48b1cd7e160b80eac" dependencies = [ - "quick-xml", + "quick-xml 0.38.4", "serde", "zbus_names", "zvariant", @@ -10512,18 +10520,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.39" +version = "0.8.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.39" +version = "0.8.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" dependencies = [ "proc-macro2", "quote", @@ -10671,9 +10679,9 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c745c48e1007337ed136dc99df34128b9faa6ed542d80a1c673cf55a6d7236c8" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" [[package]] name = "zmij" @@ -10755,7 +10763,7 @@ dependencies = [ "enumflags2", "serde", "url", - "winnow 0.7.14", + "winnow 0.7.15", "zvariant_derive", "zvariant_utils", ] @@ -10766,7 +10774,7 @@ version = "5.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.117", @@ -10783,7 +10791,7 @@ dependencies = [ "quote", "serde", "syn 2.0.117", - "winnow 0.7.14", + "winnow 0.7.15", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 18f6c4ea0..4a5a7c9b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ egui_commonmark = "0.22.0" rfd = "0.17.2" qrcode = "0.14.1" nix = { version = "0.31.1", features = ["signal"] } -eframe = { version = "0.33.3", features = ["persistence"] } +eframe = { version = "0.33.3", features = ["persistence", "wgpu"] } base64 = "0.22.1" dash-sdk = { git = "https://github.com/dashpay/platform", branch = "feat/zk", features = [ "core_key_wallet", @@ -96,6 +96,12 @@ egui_kittest = { version = "0.33.3", features = ["eframe"] } [build-dependencies] winres = "0.1" +# Keep full debuginfo for our code, but only line tables for dependencies. +# This shrinks DWARF from ~790MB to a fraction, letting gimli (std's backtrace +# symbolizer) actually resolve panic backtraces instead of printing . +[profile.dev.package."*"] +debug = "line-tables-only" + [lints.rust.unexpected_cfgs] level = "warn" check-cfg = ["cfg(tokio_unstable)", "cfg(feature, values(\"testing\"))"] diff --git a/docs/ai-design/2025-02-25-spv-peer-rework/manual-tests.md b/docs/ai-design/2025-02-25-spv-peer-rework/manual-tests.md new file mode 100644 index 000000000..41aeecaa5 --- /dev/null +++ b/docs/ai-design/2025-02-25-spv-peer-rework/manual-tests.md @@ -0,0 +1,254 @@ +# Manual Test Scenarios: SPV Peer Status Rework + +**Feature:** Replace SPV auto-stop with Connecting state and degraded-state warning +**Branch:** `fix/spv-peer-timeout` +**Date:** 2025-02-25 + +## Overview + +These scenarios verify the reworked SPV peer connection lifecycle: + +- SPV **no longer auto-stops** on peer timeout -- it keeps running and retrying peer discovery. +- New **four-state** connection indicator: Red (Disconnected), Orange/fast-pulse (Connecting), Orange/slow-pulse (Syncing), Green (Synced). +- After ~30 seconds with zero peers, a **degraded warning** appears in the tooltip ("Having trouble finding peers...") but SPV stays running. + +--- + +## Global Preconditions + +1. Dash Evo Tool is installed and launches successfully. +2. The application is configured for **SPV mode** unless stated otherwise. +3. At least one network (Testnet or Mainnet) is configured with valid DAPI endpoints. +4. Tester has access to network controls (firewall rules, VPN toggle) to simulate peer availability. + +--- + +## Scenario 1: Fresh SPV Start on Good Network + +**Goal:** Verify the Connecting -> Syncing -> Synced progression. + +### Steps + +1. Launch the application with normal network connectivity. +2. Observe the connection indicator immediately after SPV starts. +3. Hover over the indicator to read the tooltip. +4. Wait for peers to connect (usually within a few seconds). +5. Observe the indicator transition during sync phases. +6. Wait for sync to complete. + +### Expected Results + +- **Step 2:** Orange circle with **fast pulse** (`time * 2.5`). Tooltip header: "Connecting...". +- **Step 4:** Once peers connect, indicator remains orange but pulse slows (`time * 1.2`). Tooltip header changes to "Syncing". SPV label shows phase progress (e.g., "SPV: Headers: 12345 / 27000 (45%)"). +- **Step 6:** Indicator turns **green** with normal pulse. Tooltip: "Ready\nSPV: Synced\nDAPI: Available (N unbanned / M total endpoints)". + +--- + +## Scenario 2: Fresh SPV Start on Bad Network (No Peers) + +**Goal:** Verify SPV stays running and shows degraded warning instead of stopping. + +### Steps + +1. Block outbound P2P peer connections (firewall on port 9999/19999). +2. Launch the application. Ensure DAPI endpoints are reachable. +3. Observe the indicator -- should show orange (Connecting). +4. Wait approximately 30 seconds. +5. Hover over the indicator to read the tooltip. +6. Continue waiting 1-2 minutes. + +### Expected Results + +- **Step 3:** Orange indicator with fast pulse. Tooltip: "Connecting...\nSPV: Starting\nDAPI: Available (...)". +- **Step 5:** After ~30s, tooltip adds: "\nHaving trouble finding peers. Check your connection." A **warning banner** also appears with the same text. +- **Step 6:** SPV **remains running** (does NOT stop). Indicator stays orange. SPV continues trying DNS lookups and peer discovery in the background. +- **Critical:** Verify NO "SPV disconnected" error banner. Verify `stop_spv()` is NOT called (check logs -- no "stopping SPV" message). +- **Recovery:** If you restore connectivity and peers connect, the warning banner **automatically clears** and the indicator transitions to Syncing/Synced. + +--- + +## Scenario 3: Peers Disconnect Mid-Sync + +**Goal:** Verify state transitions back to Connecting (not Disconnected) when peers drop. + +### Steps + +1. Start SPV and wait for it to begin syncing with peers (orange, slow pulse). +2. Block all peer connections via firewall. +3. Observe the indicator over the next 5-10 seconds. + +### Expected Results + +- **Step 2:** Peer count drops to 0. The `spv_no_peers_since` timer starts. +- **Step 3:** Indicator changes from slow-pulse orange (Syncing) to **fast-pulse orange (Connecting)**. SPV status remains `Syncing` but with 0 peers, `refresh_state()` maps this to `Connecting`. +- SPV does NOT stop. No error banner. +- After 30s of no peers, tooltip adds "Having trouble finding peers...". + +--- + +## Scenario 4: Peers Reconnect After Disconnect + +**Goal:** Verify seamless recovery when peers become available again. + +### Steps + +1. Complete Scenario 3 (SPV is in Connecting state, no peers). +2. Restore network connectivity (remove firewall rule). +3. Observe the indicator. + +### Expected Results + +- **Step 2-3:** Peers reconnect via SPV's internal discovery. `spv_no_peers_since` is cleared (`peers > 0` resets to `None`). +- Indicator transitions from fast-pulse orange (Connecting) to slow-pulse orange (Syncing) as peers connect and sync resumes. +- Eventually reaches green (Synced) once sync completes. +- No manual restart needed -- SPV recovered on its own. + +--- + +## Scenario 5: Network Switch + +**Goal:** Verify connection state resets cleanly on network switch. + +### Steps + +1. Confirm SPV is synced on current network (green indicator). +2. Switch to a different network via the network chooser. +3. Observe the indicator immediately after switch. +4. Wait for SPV to start on the new network. + +### Expected Results + +- **Step 2:** `ConnectionStatus::reset()` clears all state: `spv_status` -> Idle, `spv_connected_peers` -> 0, `spv_no_peers_since` -> None, `overall_state` -> Disconnected. +- **Step 3:** Indicator turns **red** momentarily. +- **Step 4:** Transitions to orange/Connecting, then Syncing, then green/Synced. + +--- + +## Scenario 6: All DAPI Endpoints Banned + +**Goal:** Verify that losing DAPI forces Disconnected even with SPV peers. + +### Steps + +1. Confirm SPV is synced (green indicator). +2. Cause all DAPI endpoints to become banned. +3. Observe the indicator after the next refresh cycle (within 1-4 seconds). + +### Expected Results + +- `refresh_state()` returns `Disconnected` because `dapi_available()` is false. +- Indicator turns **red**, regardless of SPV peer status. +- Tooltip: "Disconnected\nSPV: Synced\nDAPI: All M endpoints banned". + +--- + +## Scenario 7: Connection Indicator Visual States + +**Goal:** Verify all four visual states render correctly. + +### Expected Results + +| State | Color | Pulse | Background glow | +|---|---|---|---| +| Disconnected | Red (`error_color`) | None (`scale = 1.0`) | Same radius as main circle | +| Connecting | Orange (`warning_color`) | Fast pulse (`1.0 + 0.2 * sin(t*2.5)`) | Pulsating with 0.3 opacity | +| Syncing | Orange (`warning_color`) | Slow pulse (`1.0 + 0.15 * sin(t*1.2)`) | Pulsating with 0.3 opacity | +| Synced | Green (`success_color`) | Normal pulse (`1.0 + 0.2 * sin(t*2.0)`) | Pulsating with 0.3 opacity | + +- Connecting and Syncing use the same orange color but differ in pulse rate. +- Only Disconnected does NOT call `repaint_animation`. + +--- + +## Scenario 8: Tooltip Text for Each State + +**Goal:** Verify tooltip accuracy across all SPV states. + +| SPV Status | Peers | Overall State | Tooltip line 1 | Tooltip line 2 | Extra | +|---|---|---|---|---|---| +| Idle | 0 | Disconnected | "Disconnected" | "SPV: Idle" | -- | +| Starting | 0 | Connecting | "Connecting..." | "SPV: Starting" | After 30s: "Having trouble finding peers..." | +| Syncing | >0 | Syncing | "Syncing" | "SPV: Headers: X / Y (Z%)" | Phase progress shown | +| Syncing | 0 | Connecting | "Connecting..." | "SPV: " | After 30s: degraded warning | +| Running | >0 | Synced | "Ready" | "SPV: Synced" | -- | +| Running | 0 | Connecting | "Connecting..." | "SPV: Synced" | After 30s: degraded warning | +| Stopping | 0 | Connecting | "Connecting..." | "SPV: Stopping" | -- | +| Stopped | 0 | Disconnected | "Disconnected" | "SPV: Stopped" | -- | + +--- + +## Scenario 9: Running State with Peers Dropping to Zero + +**Goal:** Verify Running (Synced) transitions correctly when peers vanish. + +### Steps + +1. Confirm green indicator (SPV Running, peers connected). +2. Block peer connections. +3. Observe the indicator over 30+ seconds. + +### Expected Results + +- Once peer count drops to 0, `refresh_state()` maps active SPV with zero peers to `Connecting` (fast orange pulse). +- **Wait** -- `Running` status means sync finished. The SPV library may transition to a different status internally if peers drop. Observe actual SpvStatus transitions. +- If SPV stays `Running` with 0 peers: indicator should remain **orange** (`Connecting`), and the `spv_no_peers_since` timer continues without calling `stop_spv()`. +- After 30s with 0 peers, degraded warning banner and tooltip appear. + +--- + +## Scenario 10: Long-Running Stability + +**Goal:** Verify no resource leaks from peer tracking. + +### Steps + +1. Launch and sync SPV fully. +2. Note memory usage (RSS). +3. Run for 1+ hour with occasional peer churn (toggle connectivity 2-3 times). +4. Check memory every 15 minutes. + +### Expected Results + +- Memory stable (no unbounded growth). +- `spv_no_peers_since` is `Option` (fixed size), `spv_connected_peers` is `AtomicU16`. +- After peer churn, returns to Synced without stale state. +- No Mutex poisoning or deadlock warnings in logs. + +--- + +## Scenario 11: RPC Mode Unaffected + +**Goal:** Verify SPV peer logic doesn't interfere with RPC mode. + +### Steps + +1. Launch in RPC mode (Dash Core wallet connected). +2. Verify indicator follows RPC/ZMQ status. +3. Stop Dash Core. +4. Observe indicator. + +### Expected Results + +- No SPV timeout logic is involved. +- Green when RPC online + ZMQ connected + DAPI available; red otherwise. +- Tooltip shows RPC-specific content. +- No "Having trouble finding peers..." or SPV-related messages. + +--- + +## Scenario 12: Connecting State vs Syncing Pulse Differentiation + +**Goal:** Verify the visual difference between Connecting and Syncing is noticeable. + +### Steps + +1. Start SPV with peers blocked -- observe Connecting state pulse. +2. Unblock peers -- observe transition to Syncing state pulse. +3. Compare the two pulse rates side by side (or record video). + +### Expected Results + +- **Connecting** pulse is noticeably faster (2.5 Hz base) with slightly larger amplitude. +- **Syncing** pulse is calmer (1.2 Hz base) with smaller amplitude. +- Both are orange -- the pulse rate is the primary visual differentiator. +- Transition between states should be smooth (no flicker or jump). diff --git a/docs/ai-design/2026-02-24-spv-sync-error-status/manual-test-scenarios.md b/docs/ai-design/2026-02-24-spv-sync-error-status/manual-test-scenarios.md new file mode 100644 index 000000000..c61838808 --- /dev/null +++ b/docs/ai-design/2026-02-24-spv-sync-error-status/manual-test-scenarios.md @@ -0,0 +1,105 @@ +# Manual Test Scenarios: SPV Sync Error Status + +## Context + +When SPV sync encounters a fatal error (e.g., masternode sync failure), the app +should transition from "Syncing" to a distinct Error state, with the connectivity +icon turning **magenta** (with "!" glyph and slow pulse) and the tooltip showing +the specific error message. + +## Prerequisites + +- Dash Evo Tool built with the fix applied +- Access to Testnet (or a network where SPV sync can be triggered) +- SPV backend mode enabled (not RPC mode) + +## Scenario 1: Verify error state on sync failure + +**Goal:** Confirm the connectivity icon transitions to Error (magenta) when +SPV sync fails, distinct from Disconnected (red). + +### Steps + +1. Launch Dash Evo Tool and connect to Testnet in SPV mode. +2. Observe the top-left connectivity icon during sync — it should pulse orange + (Syncing state). +3. If sync completes successfully, the icon should turn green (Running state). +4. If sync fails (e.g., masternode QRInfo failure visible in logs), observe: + - The connectivity icon turns **magenta** with a slow pulsation and a white + **"!"** glyph in the center. + - Hovering over the icon shows tooltip: **"SPV sync error: {detail}"** with + the specific error message (e.g., "Sync manager Masternode failed: ..."). + - Below that: **"SPV: Error"** detail line. +5. Open the Network Chooser screen and check the SPV status detail — it should + display the error message. + +### Expected Result + +- Icon transitions from orange (Syncing) to magenta (Error) on sync failure. +- Error icon is visually distinct from red (Disconnected) — magenta color, + slow pulse, "!" glyph. +- Tooltip shows "SPV sync error: ..." with the specific error message. +- Error message is visible in the status detail panel. + +## Scenario 2: Verify normal sync still works + +**Goal:** Confirm the fix doesn't break the happy path. + +### Steps + +1. Launch Dash Evo Tool and connect to Testnet in SPV mode. +2. Wait for sync to complete (may take several minutes on first sync). +3. Observe the connectivity icon transitions: + - Orange (Syncing) during sync. + - Green (Running) after sync completes. +4. Hover over the icon — tooltip should show "SPV synced" with "SPV: Running". + +### Expected Result + +- Sync completes normally, icon turns green. +- No false error transitions during normal sync. + +## Scenario 3: Verify error message content + +**Goal:** Confirm the error message stored in `last_error` contains useful +diagnostic information. + +### Steps + +1. Trigger an SPV sync that fails (e.g., by connecting to a network with + known chain lock propagation issues). +2. Check application logs for the error: + - Look for `SPV manager ... reported error: ...` log line. +3. Hover over the connectivity icon and verify the tooltip shows the same + error message (not a generic "Sync failed" without context). + +### Expected Result + +- Log contains `SPV manager "Masternode" reported error: Masternode sync failed: ...`. +- Tooltip shows the specific error from the sync manager, including + the block hash reference. + +## Scenario 4: Verify Error state is distinct from Disconnected + +**Goal:** Confirm the user can visually distinguish Error from Disconnected. + +### Steps + +1. With the app in SPV mode, trigger a sync error (Scenario 1). +2. Note the icon appearance: magenta, pulsating, "!" glyph. +3. Switch to a network with no connectivity (e.g., disconnect network). +4. Note the icon appearance: red, static, no glyph. + +### Expected Result + +- Error state: magenta circle, slow pulse, white "!" glyph. +- Disconnected state: red circle, static (no pulse), no glyph. +- The two states are clearly visually distinguishable. + +## Notes + +- The actual QRInfo chain lock error is an upstream issue + (dashpay/rust-dashcore#470). This fix ensures the app **reports** the error + correctly rather than silently staying stuck in "Syncing". +- A separate upstream issue (dashpay/rust-dashcore#469) tracks the missing + `try_emit_progress()` call on error paths in dash-spv. diff --git a/docs/ai-design/2026-02-27-banner-review-fixes/pr-604-review-guide.md b/docs/ai-design/2026-02-27-banner-review-fixes/pr-604-review-guide.md new file mode 100644 index 000000000..ac84542b0 --- /dev/null +++ b/docs/ai-design/2026-02-27-banner-review-fixes/pr-604-review-guide.md @@ -0,0 +1,219 @@ +# PR #604 — Manual Review Guide + +Key files for manual review: architectural decisions, reusable patterns, and behavioral changes. +Files that merely apply patterns defined here are excluded (~57 files). + +**Diff summary**: 83 files changed, 3,106 insertions, 3,739 deletions (net −633 lines). + +--- + +## 1. Core Infrastructure + +### [`src/ui/components/message_banner.rs`](https://github.com/dashpay/dash-evo-tool/pull/604/files#diff-5c97c5af2fd0a32afba3e66e5f6a5f7acafb9a73da0d3b89a92f8fc9fca06a35) + +Heart of the PR. All other changes depend on patterns defined here. + +| What | Notes | +|---|---| +| `set_global` idempotency — no longer resets existing banners | Subtle semantic change; all callers rely on this | +| `replace_global` — docs + empty-text semantics | Used for progress update sequences | +| `with_details` takes `impl Debug` instead of `&str` | API broadening | +| `ResultBannerExt` — `or_show_error()` on `Result` | Shows error banner, passes `self` through unchanged | +| `OptionBannerShowExt` — `or_show_error()` on `Option` | Shows named error banner when `None`, passes `self` through | +| `OptionBannerExt` — `take_and_clear()` on `Option` | Clears progress banner without leaking the handle | +| SEC-003: Eviction log no longer includes message text | Privacy fix | + +**Extension trait summary**: + +```rust +// Result — show banner on Err, return self unchanged +result.or_show_error(ctx) + +// Option — show named banner on None, return self unchanged +option.or_show_error(ctx, "message") + +// Option — take handle + clear banner atomically +self.refresh_banner.take_and_clear() +``` + +**Review focus**: Verify the idempotency change in `set_global` (old behavior reset existing banners, new behavior is a no-op). This subtle semantic change affects all callers. + +--- + +## 2. Behavioral / Architectural Decisions + +### [`src/ui/mod.rs` — ScreenLike trait](https://github.com/dashpay/dash-evo-tool/pull/604/files#diff-d64f1e2614b0de74ea77854bc2fb944deaaa5b40a4e37b91a81a94da9bd5ebf8) + +`display_task_result` default changed from showing "Success" banner to **no-op**. Breaking behavioral change — screens that relied on the default now silently swallow success results. `display_message` contract is also clarified: screens only implement it for side-effects (e.g., clearing a progress banner); banner display is handled centrally by `AppState`. + +| What | Notes | +|---|---| +| `display_task_result` default → no-op | AppState now owns success banner display | +| `display_message` contract clarified | Side-effects only; all 60+ screen impls are boilerplate no-ops | + +**Review focus**: Verify that `AppState` handles success banners centrally (see `src/app.rs` below), and no screen depended on the old default showing "Success". + +### [`src/app.rs` — Task result routing + connection banner](https://github.com/dashpay/dash-evo-tool/pull/604/files#diff-8c6f1be9c6b6eb6dc2c76e6a6f2706d76f81aad0ff222c5a9ef4eab78acee7b5) + +Two distinct changes: + +**Centralized task result dispatch** (around L1023): +- `TaskResult::Success(Message)` → `MessageBanner::set_global` + `display_task_result` +- `TaskResult::Success(_)` catch-all → delegates to screen's `display_task_result` only (no global banner) +- `TaskResult::Error` → `MessageBanner::set_global` + optional debug details in developer mode + `display_message` side-effect + +**Connection banner state machine** (around L881–960): + +| What | Notes | +|---|---| +| `previous_connection_state` + `connection_banner_handle` fields | Track state for FSM | +| `clear_all_global` on network switch | Stale banners cleared when user changes network | +| `update_connection_banner()` — Disconnected/Syncing/Synced FSM | Replaces ad-hoc string matching in connection_status.rs | + +**Review focus**: The FSM uses `OverallConnectionState` equality to suppress redundant banner updates. Verify state transitions cover edge cases (rapid Disconnected→Syncing→Disconnected). Verify `clear_all_global` on network switch does not clear banners that should persist. + +### [`src/context/connection_status.rs`](https://github.com/dashpay/dash-evo-tool/pull/604/files#diff-75d4306c0e7eca30d7e0dbb01b6f6b3d3b3af1e65a22e6de68aa6c92d9b3779f) + +Removed the `contains("Failed to get best chain lock...")` error-string-matching handler. The connection banner FSM in `app.rs` now owns this responsibility. + +**Review focus**: Confirm the removed code path is fully covered by `update_connection_banner()` in `app.rs`, and that `ChainLocks` task no longer bails out when all networks fail (it now returns all-None as a valid result). + +### [`src/backend_task/core/mod.rs`](https://github.com/dashpay/dash-evo-tool/pull/604/files#diff-...) — `build_unsigned_payment_tx` + +`WalletManager::create_unsigned_payment_transaction` was removed upstream. This PR introduces a local replacement using `TransactionBuilder` directly. + +| What | Notes | +|---|---| +| `build_unsigned_payment_tx` helper | Replaces removed SDK method; uses `SelectionStrategy::OptimalConsolidation` | +| `FeeLevel::Normal` → `FeeRate::normal()` | SDK type rename | +| All-None ChainLocks result now valid | Removed the early-bail error; returns whatever networks succeeded | + +**Review focus**: The new `build_unsigned_payment_tx` manually assembles a transaction (change address, UTXO selection, output creation). Verify the `WalletError` mapping on selection failure (insufficient funds vs. generic build error). + +### [`src/spv/manager.rs`](https://github.com/dashpay/dash-evo-tool/pull/604/files#diff-...) — ArcSwapOption migration + +`client_interface: Arc>>` replaced with `spv_client: ArcSwapOption`. + +| What | Notes | +|---|---| +| `ArcSwapOption` for `spv_client` | Wait-free reads; eliminates lock contention on quorum lookups | +| `get_quorum_public_key` uses `load_full()` | No lock held across the async `block_on` call | +| `stop()` comment — no explicit clear needed | Client is cleared asynchronously when it stops | +| `client.start()` removed; client wrapped in `Arc` before storing | Lifecycle change — verify start/stop symmetry | + +**Review focus**: The old code called `client.start()` explicitly before storing the interface. The new code stores the `Arc` without a separate start call. Confirm the SPV client starts implicitly (e.g., on construction) and that the stop path correctly sets `spv_client` to None. + +### [`src/ui/identities/mod.rs` — `get_selected_wallet` API](https://github.com/dashpay/dash-evo-tool/pull/604/files#diff-...) + +Out-param error pattern replaced with `Result`. + +| Old signature | New signature | +|---|---| +| `fn get_selected_wallet(..., error_message: &mut Option) -> Option>>` | `fn get_selected_wallet(...) -> Result>>, String>` | + +**Review focus**: All callers updated. Verify no call site silently drops the `Err` variant. + +### [`src/ui/tokens/mod.rs` — Shared helpers](https://github.com/dashpay/dash-evo-tool/pull/604/files#diff-0e09ce3e1e56a10a8d2cef2e29e7addb8e5a4dff5f78b8e15c50cdc94ef53e06) + +Three helpers shared across ~15 token screens, reducing duplication. + +| Helper | Notes | +|---|---| +| `load_identities_with_banner()` | Load identities + show error banner on failure | +| `set_error_banner()` | Thin wrapper around `MessageBanner::set_global` with Error type | +| `validate_signing_key()` | Signature: `&Option` → returns `Option<&T>` | + +--- + +## 3. Key Behavioral Changes (High Impact) + +### Constructor panics eliminated + +All screen constructors previously used `.expect()` for DB and identity loads. These now use `or_show_error()` or `MessageBanner::set_global` + graceful degraded state (empty list / zero balance). No constructor returns an error — callers remain clean. + +**Representative example**: `src/ui/tokens/claim_tokens_screen.rs` — single DB load + `or_show_error` + `.unwrap_or_default()` fallback. + +### Fee-aware validation — `src/ui/identities/transfer_screen.rs` + +Amount validation now checks estimated fee before allowing submission: + +```rust +let estimated_fee = self.app_context.fee_estimator().estimate_credit_transfer(); +let max_transferable = (identity.balance() as u128).saturating_sub(estimated_fee as u128); +if credits > max_transferable { + // error banner: "Amount plus estimated fee exceeds available balance (max: ...)" +} +``` + +`TransferCreditsStatus` enum simplified: `WaitingForResult(TimestampMillis)` → `WaitingForResult` (timestamp removed), `ErrorMessage(String)` → `Error` (string moved to global banner). + +### Transfer tokens refresh filters by contract+position — `src/ui/tokens/transfer_tokens_screen.rs` + +After a successful transfer, the refresh now filters the returned identity list by `data_contract_id` AND `token_position` to find the correct updated balance, rather than a naive first-match. + +### QR scanner correct result handling — `src/ui/dashpay/qr_scanner.rs` + +`parse_qr_code` previously called `self.display_message(...)` for all outcomes. Now uses `MessageBanner::set_global` directly. The `message: Option<(String, MessageType)>` field and inline rendering are removed. + +### Broadcast status screens — elapsed rendering migrated to banner + +`register_contract_screen.rs`, `update_contract_screen.rs`, `document_action_screen.rs` no longer store timestamps in status enum variants. Elapsed time is rendered via the `BannerHandle::with_elapsed` mechanism on the progress banner. + +### Database error handling restored — `src/ui/dashpay/contacts_list.rs` + +Contact update errors that were previously silently dropped now surface via `MessageBanner::set_global` with `tracing::error!`. + +--- + +## 4. Reusable Pattern Examples (Representative) + +### [`src/ui/tokens/claim_tokens_screen.rs`](https://github.com/dashpay/dash-evo-tool/pull/604/files#...) — Constructor pattern + +Best example of the panic-elimination pattern: DB load + `or_show_error` + `.unwrap_or_default()` + `WaitingForResult` enum simplification (timestamp dropped). + +### [`src/ui/identities/add_existing_identity_screen.rs`](https://github.com/dashpay/dash-evo-tool/pull/604/files#...) — Status enum simplification + +`AddIdentityStatus::Error` changed from `Error(String)` to unit variant `Error`. Error text lives in the global banner. Constructor init error uses `MessageBanner::set_global`. + +### [`src/ui/wallets/send_screen.rs`](https://github.com/dashpay/dash-evo-tool/pull/604/files#...) — BannerHandle lifecycle + +Most complete example of the `BannerHandle` lifecycle: progress banner with elapsed time, cleared on result via `take_and_clear()`, with a `set_send_progress_banner()` helper. + +--- + +## 5. Conventions / Docs + +### [`CLAUDE.md`](https://github.com/dashpay/dash-evo-tool/pull/604/files#diff-82d8aa1d8fd8d9b71c3cf5e7fcade1d8697cc47c6df2e1b0c77ed5b0f01e5e93) + +Updated conventions affect all future development. + +| What | +|---| +| Constructor error handling: `or_show_error` + degraded state, no `expect()` | +| `BannerHandle` lifecycle: `refresh_banner` field, `take_and_clear()` on task arrival | +| `display_message` contract: side-effects only | + +--- + +## Summary: files to review carefully vs. files to skim + +| Priority | File | What to verify | +|---|---|---| +| Critical | `src/ui/components/message_banner.rs` | `set_global` idempotency, extension trait contracts | +| Critical | `src/ui/mod.rs` | `display_task_result` default → no-op; `display_message` contract | +| Critical | `src/app.rs` | Task result dispatch logic; connection banner FSM; `clear_all_global` on switch | +| High | `src/backend_task/core/mod.rs` | `build_unsigned_payment_tx` correctness; WalletError mapping; all-None ChainLocks | +| High | `src/spv/manager.rs` | ArcSwapOption migration; SPV client start/stop lifecycle | +| High | `src/context/connection_status.rs` | Removed error-string handler — covered by FSM in app.rs? | +| High | `src/ui/identities/mod.rs` | `get_selected_wallet` API: no caller silently drops `Err` | +| Medium | `src/ui/tokens/mod.rs` | Shared helpers; `validate_signing_key` signature | +| Medium | `src/ui/identities/transfer_screen.rs` | Fee-aware validation; status enum simplification | +| Medium | `src/ui/tokens/transfer_tokens_screen.rs` | Refresh filter by contract+position | +| Medium | `src/ui/dashpay/contacts_list.rs` | DB error handling restored | +| Low | `src/ui/tokens/claim_tokens_screen.rs` | Constructor pattern example | +| Low | `src/ui/identities/add_existing_identity_screen.rs` | Status enum example | +| Low | `src/ui/wallets/send_screen.rs` | BannerHandle lifecycle example | +| Low | `CLAUDE.md` | Convention accuracy | +| Skip | ~57 remaining screen files | Mechanical application of the patterns above | + +The ~57 skipped files all apply the same pattern: remove `error_message: Option` field, remove inline error rendering, remove `check_message_expiration`, replace `display_message` with `MessageBanner::set_global` calls, and add a boilerplate `display_message` no-op. If the patterns in the files above are correct, those files are correct by construction. diff --git a/docs/ai-design/2026-03-03-fix-nonce-reset-on-refresh/manual-test-scenarios.md b/docs/ai-design/2026-03-03-fix-nonce-reset-on-refresh/manual-test-scenarios.md new file mode 100644 index 000000000..35c9ed6f8 --- /dev/null +++ b/docs/ai-design/2026-03-03-fix-nonce-reset-on-refresh/manual-test-scenarios.md @@ -0,0 +1,61 @@ +# Manual Test Scenarios: Fix Nonce Reset on Refresh (#652) + +## Prerequisites +- Dash Evo Tool running and connected to a network (Testnet or Devnet) +- An HD wallet with at least one Platform Payment address that has been used (nonce > 0) +- If no address has nonce > 0, perform a Platform transaction first (e.g., transfer credits) + +## Test Scenario 1: Refresh All preserves nonces (default mode) + +**Steps:** +1. Open Wallets screen +2. Select an HD wallet +3. Navigate to Platform Payment addresses (click the Platform account) +4. Note the nonce values for addresses with nonce > 0 +5. Click the Refresh button (default "Core + Platform" mode) +6. Wait for refresh to complete + +**Expected:** All nonce values remain unchanged after refresh. Addresses that had nonce > 0 still show the same nonce. + +## Test Scenario 2: Platform Only refresh preserves nonces + +**Steps:** +1. Open Wallets screen and select an HD wallet +2. Navigate to Platform Payment addresses +3. Note the nonce values +4. Switch refresh mode to "Platform Only" (dev mode dropdown) +5. Click Refresh +6. Wait for refresh to complete + +**Expected:** Nonce values remain unchanged. Balances may update if changed on-chain. + +## Test Scenario 3: Nonce updates correctly after new transaction + +**Steps:** +1. Open Wallets screen and note current nonce for a Platform address +2. Perform a Platform transaction using that address (e.g., transfer credits) +3. After transaction completes, note the updated nonce +4. Click Refresh +5. Wait for refresh to complete + +**Expected:** Nonce reflects the post-transaction value both before and after refresh. + +## Test Scenario 4: Zero-balance addresses retain nonces + +**Steps:** +1. Have a Platform address that was previously funded (nonce > 0) but now has 0 balance (credits were withdrawn or transferred out) +2. Navigate to Platform Payment addresses +3. Enable "Show zero-balance addresses" if the address is hidden +4. Note the nonce value +5. Click Refresh + +**Expected:** The address retains its nonce value even though balance is 0. + +## Test Scenario 5: Locked wallet shows error on refresh + +**Steps:** +1. Open a password-protected wallet +2. Lock the wallet +3. Attempt to click Refresh for Platform sync + +**Expected:** Error message indicating wallet must be unlocked. Nonces from before locking are unchanged. diff --git a/docs/ai-design/2026-03-05-banner-details-overlap/manual-test.md b/docs/ai-design/2026-03-05-banner-details-overlap/manual-test.md new file mode 100644 index 000000000..3d73e7f67 --- /dev/null +++ b/docs/ai-design/2026-03-05-banner-details-overlap/manual-test.md @@ -0,0 +1,24 @@ +# Manual Test: Banner Details Overlap Fix (#681) + +## Prerequisites +- Developer mode enabled (to see "Show details" links on error banners) +- Ability to trigger multiple backend errors (e.g., invalid network config, expired identity operations) + +## Test Scenario: Multiple Expanded Details + +1. Trigger 2+ error banners that include technical details (e.g., attempt operations on a disconnected network, or perform actions that produce different errors in sequence) +2. Verify all banners appear stacked vertically without overlap +3. Click "Show details" on the **first** banner — verify the details section expands inline, pushing subsequent banners down +4. Click "Show details" on the **second** banner — verify its details section also expands without overlapping the first +5. Scroll within each details section independently — verify scroll positions are independent (no shared state) +6. Click "Hide details" on one banner — verify only that banner's details collapse; the other remains expanded +7. Dismiss one banner — verify remaining banners reflow correctly + +## Expected Result +- Each banner's details section occupies its own vertical space +- No visual overlap between expanded details of different banners +- Scroll areas within each details section are independent + +## Regression Check +- Single banner with "Show details" still works as before +- Banners without details are unaffected diff --git a/src/app.rs b/src/app.rs index a8ced6cb6..03081d9e1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,15 +3,17 @@ use crate::app_dir::app_user_data_file_path; use crate::app_dir::{copy_env_file_if_not_exists, create_app_user_data_directory_if_not_exists}; use crate::backend_task::contested_names::ContestedResourceTask; use crate::backend_task::core::CoreItem; +use crate::backend_task::error::TaskError; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::components::core_zmq_listener::{CoreZMQListener, ZMQMessage}; use crate::context::AppContext; -use crate::context::connection_status::ConnectionStatus; +use crate::context::connection_status::{ConnectionStatus, OverallConnectionState}; use crate::database::Database; #[cfg(not(feature = "testing"))] use crate::logging::initialize_logger; use crate::model::settings::Settings; -use crate::ui::components::MessageBanner; +use crate::spv::CoreBackendMode; +use crate::ui::components::{BannerHandle, MessageBanner}; use crate::ui::contracts_documents::contracts_documents_screen::DocumentQueryScreen; use crate::ui::dashpay::{DashPayScreen, DashPaySubscreen, ProfileSearchScreen}; use crate::ui::dpns::dpns_contested_names_screen::{ @@ -50,11 +52,11 @@ use tokio::sync::mpsc as tokiompsc; pub enum TaskResult { Refresh, Success(Box), - Error(String), + Error(TaskError), } -impl From> for TaskResult { - fn from(value: Result) -> Self { +impl From> for TaskResult { + fn from(value: Result) -> Self { match value { Ok(value) => TaskResult::Success(Box::new(value)), Err(e) => TaskResult::Error(e), @@ -91,6 +93,17 @@ pub struct AppState { pub show_welcome_screen: bool, /// The welcome screen instance (only created if needed) pub welcome_screen: Option, + /// Previous connection state, used to detect transitions and update banners. + /// `None` on startup / after network switch to force the first evaluation. + previous_connection_state: Option, + /// Handle to the current connection status banner, if one is displayed + connection_banner_handle: Option, + /// Async shutdown receiver. `Some` while a graceful shutdown is in progress; + /// the viewport is closed once the receiver resolves. + shutdown_receiver: Option>, + /// Timestamp when the async shutdown was initiated, used as a hard deadline + /// to force-close the viewport if the shutdown task stalls. + shutdown_started: Option, } #[derive(Debug, Clone, PartialEq)] @@ -705,6 +718,10 @@ impl AppState { subtasks, show_welcome_screen: !onboarding_completed, welcome_screen: None, + previous_connection_state: None, + connection_banner_handle: None, + shutdown_receiver: None, + shutdown_started: None, }; // Initialize welcome screen if needed (after mainnet_app_context is owned by the struct) @@ -873,12 +890,96 @@ impl AppState { self.chosen_network = network; let app_context = self.current_app_context().clone(); + // INTENTIONAL(SEC-004): Clear stale banners from the previous network context. + // A backend task completing after the switch could set a new banner in the new + // network context — accepted risk for a local desktop app (cosmetic only). + MessageBanner::clear_all_global(app_context.egui_ctx()); + for screen in self.main_screens.values_mut() { screen.change_context(app_context.clone()) } self.connection_status .reset(app_context.core_backend_mode()); + + // Reset connection banner tracking so the next frame re-evaluates + // the new network's state (even if it matches the old state). + if let Some(handle) = self.connection_banner_handle.take() { + handle.clear(); + } + self.previous_connection_state = None; + } + + /// Update the connection status banner when the overall connection state + /// transitions between Disconnected, Connecting, Syncing, and Synced. + /// + /// Also re-evaluates the banner text while in `Connecting` state each frame + /// because the degraded-peer timeout can fire without a state transition. + fn update_connection_banner(&mut self, ctx: &egui::Context, app_context: &Arc) { + let connection_status = app_context.connection_status(); + let current_state = connection_status.overall_state(); + let state_changed = self.previous_connection_state != Some(current_state); + + // In Connecting state the banner text can change (normal → degraded) + // without a state transition, so we must re-evaluate every frame. + // For all other states, skip if nothing changed. + if !state_changed && current_state != OverallConnectionState::Connecting { + return; + } + + // Clear old banner on state transitions + if state_changed && let Some(handle) = self.connection_banner_handle.take() { + handle.clear(); + } + + // Display new banner based on current state + let backend_mode = connection_status.backend_mode(); + match current_state { + OverallConnectionState::Disconnected => { + let msg = match backend_mode { + CoreBackendMode::Rpc => "Disconnected — check that Dash Core is running", + CoreBackendMode::Spv => "Disconnected — check your internet connection", + }; + self.connection_banner_handle = + Some(MessageBanner::set_global(ctx, msg, MessageType::Error)); + } + OverallConnectionState::Connecting => { + // SPV active but no peers connected yet. The degraded flag + // flips after 30 s — `set_global` is idempotent for same text, + // so calling it every frame while Connecting is cheap. + let msg = if connection_status.spv_peer_degraded() { + "Having trouble finding peers. Check your connection." + } else { + "Looking for peers…" + }; + // Replace the banner when the text changes (normal → degraded). + if let Some(handle) = &self.connection_banner_handle { + handle.set_message(msg); + } else { + self.connection_banner_handle = + Some(MessageBanner::set_global(ctx, msg, MessageType::Warning)); + } + } + OverallConnectionState::Syncing => { + let msg = match backend_mode { + CoreBackendMode::Rpc => "Syncing with Dash Core…", + CoreBackendMode::Spv => "SPV sync in progress…", + }; + self.connection_banner_handle = + Some(MessageBanner::set_global(ctx, msg, MessageType::Warning)); + } + OverallConnectionState::Error => { + self.connection_banner_handle = Some(MessageBanner::set_global( + ctx, + "SPV sync error — check connection status for details", + MessageType::Error, + )); + } + OverallConnectionState::Synced => { + // No banner needed for fully synced state + } + } + self.previous_connection_state = Some(current_state); } pub fn visible_screen_mut(&mut self) -> &mut Screen { @@ -927,6 +1028,66 @@ impl AppState { impl App for AppState { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // ── Graceful shutdown: intercept window close so the UI stays responsive ── + // When the user closes the window we cancel the native close, show a banner, + // and start an async shutdown. Once all tasks have finished (or timed out) + // we issue Close ourselves. + if let Some(rx) = &mut self.shutdown_receiver { + // Shutdown already in progress — check if it's done. + let should_close = match rx.try_recv() { + Ok(()) => { + tracing::debug!("Async shutdown finished, closing viewport"); + true + } + Err(tokio::sync::oneshot::error::TryRecvError::Closed) => { + // Sender dropped without sending — shutdown task likely panicked. + tracing::warn!("Shutdown channel closed unexpectedly (possible panic)"); + true + } + Err(tokio::sync::oneshot::error::TryRecvError::Empty) => { + // Still waiting — check hard deadline to prevent infinite loop. + if let Some(started) = self.shutdown_started { + let grace = crate::utils::tasks::SHUTDOWN_TIMEOUT + + std::time::Duration::from_secs(5); + if started.elapsed() > grace { + tracing::warn!( + "Shutdown hard deadline exceeded, force-closing viewport" + ); + true + } else { + false + } + } else { + false + } + } + }; + if should_close { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } else { + ctx.request_repaint(); + } + // Render a minimal UI that shows the shutdown banner. + crate::ui::theme::apply_theme(ctx, self.theme_preference); + crate::ui::components::styled::island_central_panel(ctx, |_ui| {}); + return; + } + + if ctx.input(|i| i.viewport().close_requested()) { + // Prevent the window from closing immediately. + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + MessageBanner::set_global( + ctx, + "Shutting down background tasks — please wait…", + MessageType::Warning, + ); + tracing::debug!("Close requested, starting async shutdown"); + self.shutdown_receiver = Some(self.subtasks.shutdown_async()); + self.shutdown_started = Some(std::time::Instant::now()); + ctx.request_repaint(); + return; + } + // Apply Dash theme with user preference crate::ui::theme::apply_theme(ctx, self.theme_preference); @@ -948,9 +1109,13 @@ impl App for AppState { BackendTaskSuccessResult::Refresh => { self.visible_screen_mut().refresh(); } - BackendTaskSuccessResult::Message(ref _msg) => { - // Let the screen handle Message via display_task_result - // so it can do custom handling (like clearing spinners) + BackendTaskSuccessResult::Message(ref msg) => { + // TODO(RUST-002): Some screens inspect Message text for error + // keywords and may override with an Error banner, causing a + // brief green-then-red flash. Refactor to pass structured error + // types through task results instead of string messages. + // See https://github.com/dashpay/dash-evo-tool/issues/660 . + MessageBanner::set_global(ctx, msg, MessageType::Success); self.visible_screen_mut() .display_task_result(unboxed_message); } @@ -990,10 +1155,18 @@ impl App for AppState { } } } - TaskResult::Error(message) => { - MessageBanner::set_global(ctx, &message, MessageType::Error); + TaskResult::Error(err) => { + let msg = err.to_string(); + let handle = MessageBanner::set_global(ctx, &msg, MessageType::Error); + 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); + } self.visible_screen_mut() - .display_message(&message, MessageType::Error); + .display_message(&msg, MessageType::Error); } TaskResult::Refresh => { self.visible_screen_mut().refresh(); @@ -1163,6 +1336,8 @@ impl App for AppState { .trigger_refresh(active_context.as_ref()), ); + self.update_connection_banner(ctx, &active_context); + for action in actions { match action { AppAction::None => {} @@ -1268,10 +1443,15 @@ impl App for AppState { } fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { - // Gracefully shutdown all background tasks, waiting for them to complete - // This ensures tasks like the dash-qt handler have time to check their settings - // and decide whether to terminate the process or leave it running - tracing::debug!("App received on_exit event, initiating graceful shutdown"); + // If shutdown_receiver is Some, the async shutdown was already initiated + // in update(). Skip the blocking fallback to avoid double-shutdown. + // The blocking path only runs when the window was force-closed without + // going through update() (e.g., OS-level kill, alt-F4 on some platforms). + if self.shutdown_receiver.is_some() { + tracing::debug!("on_exit: async shutdown was initiated, skipping blocking fallback"); + return; + } + tracing::debug!("on_exit: fallback blocking shutdown"); if let Err(e) = self.subtasks.shutdown() { tracing::error!("Error during task shutdown: {}", e); } diff --git a/src/backend_task/contested_names/query_dpns_contested_resources.rs b/src/backend_task/contested_names/query_dpns_contested_resources.rs index 1b02cac28..aadf03706 100644 --- a/src/backend_task/contested_names/query_dpns_contested_resources.rs +++ b/src/backend_task/contested_names/query_dpns_contested_resources.rs @@ -197,7 +197,7 @@ impl AppContext { } Err(e) => { tracing::error!("Error querying dpns end times: {}", e); - if let Err(send_err) = sender.send(TaskResult::Error(e)).await { + if let Err(send_err) = sender.send(TaskResult::Error(e.into())).await { tracing::warn!( "Failed to send error for dpns end times query: {}", send_err @@ -249,7 +249,7 @@ impl AppContext { } Err(e) => { tracing::error!("Error querying dpns vote contenders for {}: {}", name, e); - if let Err(send_err) = sender.send(TaskResult::Error(e)).await { + if let Err(send_err) = sender.send(TaskResult::Error(e.into())).await { tracing::warn!( "Failed to send error for vote contenders query for {}: {}", name, diff --git a/src/backend_task/core/mod.rs b/src/backend_task/core/mod.rs index 3795b9dfd..ef281a7bb 100644 --- a/src/backend_task/core/mod.rs +++ b/src/backend_task/core/mod.rs @@ -23,7 +23,11 @@ use dash_sdk::dpp::dashcore::{ use dash_sdk::dpp::fee::Credits; use dash_sdk::dpp::key_wallet::Network as WalletNetwork; use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; -use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::fee::FeeLevel; +use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::coin_selection::SelectionStrategy; +use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::fee::{FeeLevel, FeeRate}; +use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::transaction_builder::{ + BuilderError, TransactionBuilder, +}; use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use dash_sdk::dpp::key_wallet_manager::wallet_manager::{WalletError, WalletId, WalletManager}; @@ -185,19 +189,7 @@ impl AppContext { let devnet_chainlock = devnet_result.ok(); let local_chainlock = local_result.ok(); - // If all three failed, bail out with an error - if mainnet_chainlock.is_none() - && testnet_chainlock.is_none() - && devnet_chainlock.is_none() - && local_chainlock.is_none() - { - return Err( - "Failed to get best chain lock for mainnet, testnet, devnet, and local network" - .to_string(), - ); - } - - // Otherwise, return the successes we have + // Return whatever we have — even all-None is valid. Ok(BackendTaskSuccessResult::CoreItem(CoreItem::ChainLocks( mainnet_chainlock, testnet_chainlock, @@ -528,23 +520,39 @@ impl AppContext { .map(|h| h.current_height()) }) .ok_or("Cannot build transaction: SPV sync height is not yet known")?; + const MAX_FEE_ITERATIONS: usize = 50; + let total_amount: u64 = recipients.iter().map(|(_, amt)| *amt).sum(); let mut scale_factor = 1.0f64; let mut attempted_fallback = false; - loop { + // Obtain change address once before the retry loop to avoid marking + // multiple addresses as used on failed fee-adjustment attempts. + let change_result = wm + .get_change_address( + wallet_id, + DEFAULT_BIP44_ACCOUNT_INDEX, + AccountTypePreference::BIP44, + true, + ) + .map_err(|e| format!("Failed to get change address: {e}"))?; + let change_address = change_result + .address + .ok_or_else(|| "No change address generated".to_string())?; + + for _ in 0..MAX_FEE_ITERATIONS { let scaled_recipients: Vec<(Address, u64)> = recipients .iter() .map(|(addr, amt)| (addr.clone(), (*amt as f64 * scale_factor) as u64)) .collect(); - match wm.create_unsigned_payment_transaction( + match Self::build_unsigned_payment_tx( + wm, wallet_id, DEFAULT_BIP44_ACCOUNT_INDEX, - Some(AccountTypePreference::BIP44), scaled_recipients, - FeeLevel::Normal, current_height, + &change_address, ) { Ok(tx) => return Ok(tx), Err(WalletError::InsufficientFunds) if request.subtract_fee_from_amount => { @@ -574,6 +582,8 @@ impl AppContext { } } } + + Err("Could not build transaction after maximum fee adjustment attempts".to_string()) } fn estimate_fallback_amount( @@ -607,10 +617,63 @@ impl AppContext { } let estimated_size = Self::estimate_p2pkh_tx_size(spendable_inputs, 1); - let fee = FeeLevel::Normal.fee_rate().calculate_fee(estimated_size); + let fee = FeeRate::normal().calculate_fee(estimated_size); Ok(spendable_total.saturating_sub(fee)) } + /// Build an unsigned payment transaction using TransactionBuilder. + fn build_unsigned_payment_tx( + wm: &mut WalletManager, + wallet_id: &WalletId, + account_index: u32, + recipients: Vec<(Address, u64)>, + current_height: u32, + change_address: &Address, + ) -> Result { + // Get spendable UTXOs from the managed wallet info + let managed_info = wm + .get_wallet_info(wallet_id) + .ok_or(WalletError::WalletNotFound(*wallet_id))?; + let collection = managed_info.accounts(); + let account = collection + .standard_bip44_accounts + .get(&account_index) + .ok_or(WalletError::AccountNotFound(account_index))?; + + let all_utxos: Vec<_> = account.utxos.values().cloned().collect(); + if all_utxos.is_empty() { + return Err(WalletError::InsufficientFunds); + } + + // Build the transaction using TransactionBuilder + let mut builder = TransactionBuilder::new() + .set_fee_level(FeeLevel::Normal) + .set_change_address(change_address.clone()); + + for (address, amount) in recipients { + builder = builder + .add_output(&address, amount) + .map_err(|e: BuilderError| WalletError::TransactionBuild(e.to_string()))?; + } + + builder = builder + .select_inputs( + &all_utxos, + SelectionStrategy::OptimalConsolidation, + current_height, + |_| None, // No private keys for unsigned transaction + ) + // TODO(RUST-002): String-based error classification — see #660 + .map_err(|e: BuilderError| match e.to_string() { + msg if msg.contains("Insufficient") => WalletError::InsufficientFunds, + msg => WalletError::TransactionBuild(msg), + })?; + + builder + .build() + .map_err(|e: BuilderError| WalletError::TransactionBuild(e.to_string())) + } + fn sign_spv_transaction( &self, wm: &mut WalletManager, diff --git a/src/backend_task/core/recover_asset_locks.rs b/src/backend_task/core/recover_asset_locks.rs index 6b72b357c..77fc87981 100644 --- a/src/backend_task/core/recover_asset_locks.rs +++ b/src/backend_task/core/recover_asset_locks.rs @@ -135,6 +135,7 @@ impl AppContext { let (chain_locked_height, proof) = if let Some(ref info) = tx_info { if info.chainlock && info.height.is_some() { let height = info.height.unwrap() as u32; + tracing::debug!("Asset lock {} is chain-locked at height {}", txid, height); ( Some(height), Some(AssetLockProof::Chain(ChainAssetLockProof { @@ -143,9 +144,19 @@ impl AppContext { })), ) } else { + tracing::debug!( + "Asset lock {} not chain-locked yet (chainlock={}, height={:?}) — proof unavailable", + txid, + info.chainlock, + info.height + ); (None, None) } } else { + tracing::debug!( + "Could not retrieve transaction info for asset lock {} — proof unavailable", + txid + ); (None, None) }; @@ -266,6 +277,7 @@ impl AppContext { let (chain_locked_height, proof) = if let Some(ref info) = tx_info { if info.chainlock && info.height.is_some() { let height = info.height.unwrap() as u32; + tracing::debug!("Asset lock {} is chain-locked at height {}", txid, height); ( Some(height), Some(AssetLockProof::Chain(ChainAssetLockProof { @@ -274,9 +286,19 @@ impl AppContext { })), ) } else { + tracing::debug!( + "Asset lock {} not chain-locked yet (chainlock={}, height={:?}) — proof unavailable", + txid, + info.chainlock, + info.height + ); (None, None) } } else { + tracing::debug!( + "Could not retrieve transaction info for asset lock {} — proof unavailable", + txid + ); (None, None) }; diff --git a/src/backend_task/core/send_single_key_wallet_payment.rs b/src/backend_task/core/send_single_key_wallet_payment.rs index 490438819..d283cbbf3 100644 --- a/src/backend_task/core/send_single_key_wallet_payment.rs +++ b/src/backend_task/core/send_single_key_wallet_payment.rs @@ -9,7 +9,7 @@ use dash_sdk::dashcore_rpc::dashcore::{Address, OutPoint, ScriptBuf, Transaction use dash_sdk::dpp::dashcore::hashes::Hash; use dash_sdk::dpp::dashcore::sighash::SighashCache; use dash_sdk::dpp::dashcore::{EcdsaSighashType, secp256k1::Secp256k1}; -use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::fee::FeeLevel; +use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::fee::FeeRate; use std::str::FromStr; use std::sync::{Arc, RwLock}; @@ -63,11 +63,9 @@ impl AppContext { // Start with an estimate assuming ~10 inputs, then refine let num_outputs = outputs.len() + 1; // +1 for change let initial_fee_estimate = Self::estimate_p2pkh_tx_size(10, num_outputs); - let initial_fee = request.override_fee.unwrap_or_else(|| { - FeeLevel::Normal - .fee_rate() - .calculate_fee(initial_fee_estimate) - }); + let initial_fee = request + .override_fee + .unwrap_or_else(|| FeeRate::normal().calculate_fee(initial_fee_estimate)); let _target_amount = total_output + initial_fee; @@ -91,7 +89,7 @@ impl AppContext { let current_size = Self::estimate_p2pkh_tx_size(selected.len(), num_outputs); let current_fee = request .override_fee - .unwrap_or_else(|| FeeLevel::Normal.fee_rate().calculate_fee(current_size)); + .unwrap_or_else(|| FeeRate::normal().calculate_fee(current_size)); if selected_total >= total_output + current_fee { break; @@ -102,7 +100,7 @@ impl AppContext { let final_size = Self::estimate_p2pkh_tx_size(selected.len(), num_outputs); let final_fee = request .override_fee - .unwrap_or_else(|| FeeLevel::Normal.fee_rate().calculate_fee(final_size)); + .unwrap_or_else(|| FeeRate::normal().calculate_fee(final_size)); if selected_total < total_output + final_fee { return Err(format!( @@ -124,7 +122,7 @@ impl AppContext { Self::estimate_p2pkh_tx_size(selected_utxos.len(), num_outputs_with_change); let fee = request .override_fee - .unwrap_or_else(|| FeeLevel::Normal.fee_rate().calculate_fee(estimated_size)); + .unwrap_or_else(|| FeeRate::normal().calculate_fee(estimated_size)); let total_input: u64 = selected_utxos.iter().map(|(_, tx_out)| tx_out.value).sum(); diff --git a/src/backend_task/core/start_dash_qt.rs b/src/backend_task/core/start_dash_qt.rs index 25ccf9dae..c2b3307f2 100644 --- a/src/backend_task/core/start_dash_qt.rs +++ b/src/backend_task/core/start_dash_qt.rs @@ -68,7 +68,6 @@ impl AppContext { let cancel = self.subtasks.cancellation_token.clone(); let db = Arc::clone(&self.db); self.subtasks.spawn_sync("dash_qt_watcher", async move { - // Wait for the process to exit or current task to be cancelled tokio::select! { exited = dash_qt.wait() => { diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs new file mode 100644 index 000000000..a77e3579d --- /dev/null +++ b/src/backend_task/error.rs @@ -0,0 +1,45 @@ +//! Typed error envelope for backend tasks. +//! +//! `Display` → user-friendly text (shown in `MessageBanner`). +//! `Debug` → variant name + fields (logged and shown in collapsible details). +//! `From` → backwards compatible with existing `Result` code. + +use thiserror::Error; + +/// App-level error envelope for backend tasks. +#[derive(Debug, Error)] +pub enum TaskError { + /// Legacy string error — backwards compatible with all existing code. + #[error("{0}")] + Generic(String), + + /// Boxed error — catch-all for errors without a dedicated variant. + #[error(transparent)] + Other(#[from] Box), + + /// SPV subsystem errors. + #[error(transparent)] + Spv(#[from] crate::spv::SpvError), + + /// DashPay domain errors. + #[error(transparent)] + DashPay(#[from] crate::backend_task::dashpay::errors::DashPayError), + + /// Configuration errors. + #[error(transparent)] + Config(#[from] crate::config::ConfigError), + + /// GroveSTARK prover errors. + #[error(transparent)] + GroveStark(#[from] crate::model::grovestark_prover::GroveSTARKError), + + /// Wallet errors. + #[error(transparent)] + Wallet(#[from] crate::database::WalletError), +} + +impl From for TaskError { + fn from(s: String) -> Self { + TaskError::Generic(s) + } +} diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index c24aea0c3..3c50323d6 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -1,4 +1,5 @@ use crate::app::TaskResult; +use crate::backend_task::error::TaskError; use crate::backend_task::contested_names::ContestedResourceTask; use crate::backend_task::contract::ContractTask; use crate::backend_task::core::{CoreItem, CoreTask}; @@ -47,6 +48,7 @@ pub mod contract; pub mod core; pub mod dashpay; pub mod document; +pub mod error; pub mod grovestark; pub mod identity; pub mod mnlist; @@ -316,7 +318,7 @@ impl AppContext { self: &Arc, tasks: Vec, sender: SenderAsync, - ) -> Vec> { + ) -> Vec> { let mut results = Vec::new(); for task in tasks { match self.run_backend_task(task, sender.clone()).await { @@ -332,7 +334,7 @@ impl AppContext { self: &Arc, tasks: Vec, sender: SenderAsync, - ) -> Vec> { + ) -> Vec> { let futures = tasks .into_iter() .map(|task| { @@ -350,45 +352,47 @@ impl AppContext { self: &Arc, task: BackendTask, sender: SenderAsync, - ) -> Result { + ) -> Result { let sdk = self.sdk.load().as_ref().clone(); match task { BackendTask::ContractTask(contract_task) => { - self.run_contract_task(*contract_task, &sdk, sender).await - } - BackendTask::ContestedResourceTask(contested_resource_task) => { - self.run_contested_resource_task(contested_resource_task, &sdk, sender) - .await + Ok(self.run_contract_task(*contract_task, &sdk, sender).await?) } + BackendTask::ContestedResourceTask(contested_resource_task) => Ok(self + .run_contested_resource_task(contested_resource_task, &sdk, sender) + .await?), BackendTask::IdentityTask(identity_task) => { - self.run_identity_task(identity_task, &sdk, sender).await + Ok(self.run_identity_task(identity_task, &sdk, sender).await?) } BackendTask::DocumentTask(document_task) => { - self.run_document_task(*document_task, &sdk).await + Ok(self.run_document_task(*document_task, &sdk).await?) } - BackendTask::CoreTask(core_task) => self.run_core_task(core_task).await, + BackendTask::CoreTask(core_task) => Ok(self.run_core_task(core_task).await?), BackendTask::DashPayTask(dashpay_task) => { - self.run_dashpay_task(*dashpay_task, &sdk).await - } - BackendTask::BroadcastStateTransition(state_transition) => { - self.broadcast_state_transition(state_transition, &sdk) - .await + Ok(self.run_dashpay_task(*dashpay_task, &sdk).await?) } + BackendTask::BroadcastStateTransition(state_transition) => Ok(self + .broadcast_state_transition(state_transition, &sdk) + .await?), BackendTask::TokenTask(token_task) => { - self.run_token_task(*token_task, &sdk, sender).await + Ok(self.run_token_task(*token_task, &sdk, sender).await?) } - BackendTask::SystemTask(system_task) => self.run_system_task(system_task, sender).await, - BackendTask::MnListTask(mnlist_task) => { - mnlist::run_mnlist_task(self, mnlist_task).await + BackendTask::SystemTask(system_task) => { + Ok(self.run_system_task(system_task, sender).await?) } - BackendTask::PlatformInfo(platform_info_task) => { - self.run_platform_info_task(platform_info_task, &sdk).await + BackendTask::MnListTask(mnlist_task) => { + Ok(mnlist::run_mnlist_task(self, mnlist_task).await?) } + BackendTask::PlatformInfo(platform_info_task) => Ok(self + .run_platform_info_task(platform_info_task, &sdk) + .await?), BackendTask::GroveSTARKTask(grovestark_task) => { - grovestark::run_grovestark_task(grovestark_task, &sdk).await + Ok(grovestark::run_grovestark_task(grovestark_task, &sdk).await?) + } + BackendTask::WalletTask(wallet_task) => Ok(self.run_wallet_task(wallet_task).await?), + BackendTask::ShieldedTask(shielded_task) => { + Ok(self.run_shielded_task(shielded_task).await?) } - BackendTask::WalletTask(wallet_task) => self.run_wallet_task(wallet_task).await, - BackendTask::ShieldedTask(shielded_task) => self.run_shielded_task(shielded_task).await, BackendTask::None => Ok(BackendTaskSuccessResult::None), } } diff --git a/src/backend_task/wallet/fetch_platform_address_balances.rs b/src/backend_task/wallet/fetch_platform_address_balances.rs index 219752505..2c2a6c120 100644 --- a/src/backend_task/wallet/fetch_platform_address_balances.rs +++ b/src/backend_task/wallet/fetch_platform_address_balances.rs @@ -152,11 +152,16 @@ impl AppContext { } } - // Return balances for result - provider - .found_balances() + // Return the wallet's complete platform_address_info, not just + // found_balances. The SDK's incremental sync only reports addresses + // whose balance changed; unchanged addresses are absent from + // found_balances but still have valid nonces in the wallet. + // Returning only found_balances would cause the UI to lose nonce + // values for stable-balance addresses (issue #652). + wallet + .platform_address_info .iter() - .map(|(addr, funds)| (addr.clone(), (funds.balance, funds.nonce))) + .map(|(addr, info)| (addr.clone(), (info.balance, info.nonce))) .collect() }; diff --git a/src/context/connection_status.rs b/src/context/connection_status.rs index e7e685247..8f127c798 100644 --- a/src/context/connection_status.rs +++ b/src/context/connection_status.rs @@ -14,23 +14,31 @@ use std::time::{Duration, Instant}; const REFRESH_CONNECTED: Duration = Duration::from_secs(4); const REFRESH_DISCONNECTED: Duration = Duration::from_secs(1); -/// Three-state connection indicator matching the UI's red/orange/green circle. +const SPV_PEER_DEGRADED_TIMEOUT: Duration = Duration::from_secs(30); + +/// Five-state connection indicator matching the UI's colored circle. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u8)] pub enum OverallConnectionState { /// No connection at all — red indicator. Disconnected = 0, + /// SPV active but no peers connected yet — orange indicator (faster pulse). + Connecting = 1, /// All subsystems connected but still syncing data — orange indicator. - Syncing = 1, + Syncing = 2, /// Fully connected and operational — green indicator. - Synced = 2, + Synced = 3, + /// Connected but sync failed — magenta indicator with "!" glyph. + Error = 4, } impl From for OverallConnectionState { fn from(v: u8) -> Self { match v { - 1 => Self::Syncing, - 2 => Self::Synced, + 1 => Self::Connecting, + 2 => Self::Syncing, + 3 => Self::Synced, + 4 => Self::Error, _ => Self::Disconnected, } } @@ -48,7 +56,14 @@ pub struct ConnectionStatus { backend_mode: AtomicU8, disable_zmq: AtomicBool, overall_state: AtomicU8, + // NOTE: Mutex (not RwLock) is intentional — single reader (tooltip hover), + // single writer (poll cycle), minimal contention. RwLock overhead not justified. + spv_last_error: Mutex>, last_update: Mutex, + spv_connected_peers: AtomicU16, + /// When SPV first entered an active state (`Starting`/`Syncing`) with zero + /// peers. Reset to `None` once peers connect or SPV stops. + spv_no_peers_since: Mutex>, dapi_total_endpoints: AtomicU16, dapi_available_endpoints: AtomicU16, } @@ -62,7 +77,10 @@ impl ConnectionStatus { backend_mode: AtomicU8::new(CoreBackendMode::Rpc.as_u8()), disable_zmq: AtomicBool::new(false), overall_state: AtomicU8::new(OverallConnectionState::Disconnected as u8), + spv_last_error: Mutex::new(None), last_update: Mutex::new(Instant::now()), + spv_connected_peers: AtomicU16::new(0), + spv_no_peers_since: Mutex::new(None), dapi_total_endpoints: AtomicU16::new(0), dapi_available_endpoints: AtomicU16::new(0), } @@ -83,14 +101,21 @@ impl ConnectionStatus { self.backend_mode .store(backend_mode.as_u8(), Ordering::Relaxed); self.disable_zmq.store(false, Ordering::Relaxed); + self.spv_connected_peers.store(0, Ordering::Relaxed); + *self + .spv_no_peers_since + .lock() + .unwrap_or_else(|e| e.into_inner()) = None; self.overall_state.store( OverallConnectionState::Disconnected as u8, Ordering::Relaxed, ); - // Set last_update to epoch so the next trigger_refresh fires immediately - if let Ok(mut last) = self.last_update.lock() { - *last = Instant::now() - REFRESH_CONNECTED; + if let Ok(mut err) = self.spv_last_error.lock() { + *err = None; } + // Set last_update to epoch so the next trigger_refresh fires immediately + *self.last_update.lock().unwrap_or_else(|e| e.into_inner()) = + Instant::now() - REFRESH_CONNECTED; } pub fn rpc_online(&self) -> bool { @@ -140,9 +165,8 @@ impl ConnectionStatus { /// Reset the throttle timer so the next `trigger_refresh()` fires immediately. pub fn reset_timer(&self) { - if let Ok(mut last) = self.last_update.lock() { - *last = Instant::now() - REFRESH_CONNECTED; - } + *self.last_update.lock().unwrap_or_else(|e| e.into_inner()) = + Instant::now() - REFRESH_CONNECTED; } pub fn dapi_total_endpoints(&self) -> u16 { @@ -176,6 +200,17 @@ impl ConnectionStatus { } } + /// Returns `true` if SPV has been active with zero connected peers + /// for longer than [`SPV_PEER_DEGRADED_TIMEOUT`]. + /// + /// If the mutex is poisoned, recovers the inner value and evaluates it. + pub fn spv_peer_degraded(&self) -> bool { + self.spv_no_peers_since + .lock() + .unwrap_or_else(|e| e.into_inner()) + .is_some_and(|since| since.elapsed() >= SPV_PEER_DEGRADED_TIMEOUT) + } + pub fn spv_connected(status: SpvStatus) -> bool { status.is_active() } @@ -184,6 +219,14 @@ impl ConnectionStatus { self.overall_state.load(Ordering::Relaxed).into() } + /// Recompute the overall connection state from the individual subsystem + /// flags. + /// + /// Each field is read with `Ordering::Relaxed` — there is no cross-field + /// synchronisation, so a single call may observe a mix of "old" and "new" + /// values. This is acceptable because the function runs on every UI + /// frame (1-4 s cadence) and any transient inconsistency self-corrects on + /// the next poll. pub fn refresh_state(&self) { let backend_mode = self.backend_mode(); let disable_zmq = self.disable_zmq(); @@ -203,11 +246,20 @@ impl ConnectionStatus { if !dapi_available { OverallConnectionState::Disconnected } else { + let has_peers = self.spv_connected_peers.load(Ordering::Relaxed) > 0; match spv_status { - SpvStatus::Running => OverallConnectionState::Synced, - SpvStatus::Starting | SpvStatus::Syncing | SpvStatus::Stopping => { - OverallConnectionState::Syncing + SpvStatus::Running if has_peers => OverallConnectionState::Synced, + SpvStatus::Running + | SpvStatus::Starting + | SpvStatus::Syncing + | SpvStatus::Stopping => { + if has_peers { + OverallConnectionState::Syncing + } else { + OverallConnectionState::Connecting + } } + SpvStatus::Error => OverallConnectionState::Error, _ => OverallConnectionState::Disconnected, } } @@ -221,6 +273,8 @@ impl ConnectionStatus { /// In SPV mode, fetches sync progress from the [`SpvManager`] to display /// a detailed phase summary (e.g. `"SPV: Headers: 12345 / 27000 (45%)"`) /// instead of the bare `"SPV: Syncing"`. + // TODO: decouple from AppContext — accept a struct with the needed fields + // (spv_manager status, settings) instead of the full context reference. pub fn tooltip_text(&self, app_context: &crate::context::AppContext) -> String { let backend_mode = self.backend_mode(); let disable_zmq = self.disable_zmq(); @@ -244,8 +298,11 @@ impl ConnectionStatus { let header = match overall { OverallConnectionState::Synced => "Connected to Dash Core Wallet", - // RPC mode doesn't currently produce Syncing, but kept for forward-compat. - OverallConnectionState::Syncing => "Syncing to Dash Core Wallet", + // RPC mode doesn't currently produce Connecting/Syncing/Error, but kept for forward-compat. + OverallConnectionState::Connecting | OverallConnectionState::Syncing => { + "Syncing to Dash Core Wallet" + } + OverallConnectionState::Error => "Connection error", OverallConnectionState::Disconnected if self.rpc_online() => { "Dash Core connection incomplete" } @@ -256,13 +313,25 @@ impl ConnectionStatus { format!("{header}\n{rpc_status}\n{zmq_status}\n{dapi_status}") } CoreBackendMode::Spv => { - let header = match overall { - OverallConnectionState::Synced => "Ready", - OverallConnectionState::Syncing => "Syncing", - OverallConnectionState::Disconnected => "Disconnected", + let header: std::borrow::Cow<'_, str> = match overall { + OverallConnectionState::Synced => "Ready".into(), + OverallConnectionState::Connecting => "Connecting...".into(), + OverallConnectionState::Syncing => "Syncing".into(), + OverallConnectionState::Error => { + let detail = self + .spv_last_error + .lock() + .ok() + .and_then(|g| g.clone()) + .unwrap_or_else(|| "unknown error".to_string()); + format!("SPV sync error: {detail}").into() + } + OverallConnectionState::Disconnected => "Disconnected".into(), }; let spv_label = if spv_status == SpvStatus::Running { "SPV: Synced".to_string() + } else if spv_status == SpvStatus::Error { + "SPV: Error".to_string() } else { app_context .spv_manager() @@ -272,7 +341,12 @@ impl ConnectionStatus { .map(|p| format!("SPV: {}", spv_phase_summary(p))) .unwrap_or_else(|| format!("SPV: {:?}", spv_status)) }; - format!("{header}\n{spv_label}\n{dapi_status}") + let degraded_warning = if self.spv_peer_degraded() { + "\nHaving trouble finding peers. Check your connection." + } else { + "" + }; + format!("{header}\n{spv_label}{degraded_warning}\n{dapi_status}") } } } @@ -295,9 +369,10 @@ impl ConnectionStatus { self.set_rpc_online(online); } + /// Updates internal connection state from a task result. pub fn handle_task_result(&self, task_result: &TaskResult, active_network: Network) { - match task_result { - TaskResult::Success(message) => match message.as_ref() { + if let TaskResult::Success(message) = task_result { + match message.as_ref() { BackendTaskSuccessResult::CoreItem(CoreItem::ChainLocks( mainnet_chainlock, testnet_chainlock, @@ -320,16 +395,7 @@ impl ConnectionStatus { } } _ => {} - }, - TaskResult::Error(message) => { - if message.contains( - "Failed to get best chain lock for mainnet, testnet, devnet, and local", - ) { - self.set_rpc_online(false); - self.refresh_state(); - } } - _ => {} } } @@ -369,10 +435,28 @@ impl ConnectionStatus { match backend_mode { CoreBackendMode::Spv => { - // SPV status is updated elsewhere - let spv_status = app_context.spv_manager().status().status; - tracing::trace!("ConnectionStatus: polled SPV status = {:?}", spv_status); - self.set_spv_status(spv_status); + let snapshot = app_context.spv_manager().status(); + tracing::trace!( + "ConnectionStatus: polled SPV status = {:?}", + snapshot.status + ); + self.set_spv_status(snapshot.status); + if let Ok(mut err) = self.spv_last_error.lock() { + *err = snapshot.last_error; + } + let peers = (snapshot.connected_peers).min(u16::MAX as usize) as u16; + self.spv_connected_peers.store(peers, Ordering::Relaxed); + + // Track how long we've been active with zero peers. + let mut since = self + .spv_no_peers_since + .lock() + .unwrap_or_else(|e| e.into_inner()); + if peers > 0 || !snapshot.status.is_active() { + *since = None; + } else if since.is_none() { + *since = Some(Instant::now()); + } } CoreBackendMode::Rpc => { // Update ZMQ status if there's a new event @@ -471,3 +555,46 @@ impl Default for ConnectionStatus { Self::new() } } + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[test] + fn spv_peer_degraded_returns_false_when_none() { + let status = ConnectionStatus::new(); + // Default state: spv_no_peers_since is None. + assert!(!status.spv_peer_degraded()); + } + + #[test] + fn spv_peer_degraded_returns_false_before_threshold() { + let status = ConnectionStatus::new(); + // Set to "just now" — well within the degraded window. + *status.spv_no_peers_since.lock().unwrap() = Some(Instant::now()); + assert!(!status.spv_peer_degraded()); + } + + #[test] + fn spv_peer_degraded_returns_true_after_threshold() { + let status = ConnectionStatus::new(); + // Set to a point beyond the degraded threshold. + *status.spv_no_peers_since.lock().unwrap() = + Some(Instant::now() - SPV_PEER_DEGRADED_TIMEOUT - Duration::from_millis(1)); + assert!(status.spv_peer_degraded()); + } + + #[test] + fn spv_peer_degraded_clears_on_reset() { + let status = ConnectionStatus::new(); + // Set to a point beyond the degraded threshold so it would fire. + *status.spv_no_peers_since.lock().unwrap() = + Some(Instant::now() - SPV_PEER_DEGRADED_TIMEOUT - Duration::from_millis(1)); + assert!(status.spv_peer_degraded()); + + // After reset the timestamp should be cleared. + status.reset(CoreBackendMode::Spv); + assert!(!status.spv_peer_degraded()); + } +} diff --git a/src/context/mod.rs b/src/context/mod.rs index e49f77c71..3c33f8398 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -362,27 +362,16 @@ impl AppContext { return None; } - // If defaulting to RPC is desired, swap provider after binding. - if app_context.core_backend_mode() == CoreBackendMode::Rpc { - if let Err(e) = app_context + // If defaulting to RPC, rebind the RPC provider (overrides SPV registration above). + if app_context.core_backend_mode() == CoreBackendMode::Rpc + && let Err(e) = app_context .rpc_context_provider .read() .map_err(|_| "RPC provider lock poisoned".to_string()) .and_then(|provider| provider.bind_app_context(app_context.clone())) - { - tracing::error!("Failed to bind RPC provider: {}", e); - return None; - } - } else { - // Ensure SDK uses the SPV provider - let provider = match app_context.spv_context_provider.read() { - Ok(p) => p.clone(), - Err(_) => { - tracing::error!("SPV provider lock poisoned"); - return None; - } - }; - app_context.sdk.load().set_context_provider(provider); + { + tracing::error!("Failed to bind RPC provider: {}", e); + return None; } app_context.bootstrap_loaded_wallets(); @@ -425,25 +414,23 @@ impl AppContext { tracing::error!("Failed to persist core backend mode: {}", e); } - // Switch SDK context provider to match the selected backend + // Switch SDK context provider to match the selected backend. + // Early returns are defensive: if code is added after this match, a failed + // bind should not proceed with a stale provider. + #[allow(clippy::needless_return)] match mode { CoreBackendMode::Spv => { - // Clone the SPV provider and bind app context on the clone - let provider = match self.spv_context_provider.read() { - Ok(p) => p.clone(), - Err(_) => { - tracing::error!("SPV provider lock poisoned"); - return; - } - }; - if let Err(e) = provider.bind_app_context(Arc::clone(self)) { + if let Err(e) = self + .spv_context_provider + .read() + .map_err(|_| "SPV provider lock poisoned".to_string()) + .and_then(|provider| provider.bind_app_context(Arc::clone(self))) + { tracing::error!("Failed to bind SPV provider: {}", e); return; } - self.sdk.load().set_context_provider(provider); } CoreBackendMode::Rpc => { - // RPC provider binding also sets itself on the SDK if let Err(e) = self .rpc_context_provider .read() @@ -451,6 +438,7 @@ impl AppContext { .and_then(|provider| provider.bind_app_context(Arc::clone(self))) { tracing::error!("Failed to bind RPC provider: {}", e); + return; } } } @@ -567,7 +555,9 @@ impl AppContext { } self.sdk.store(Arc::new(new_sdk)); - // Rebind providers to ensure they hold the new AppContext reference + // Rebind providers to ensure they hold the new AppContext reference. + // bind_app_context also registers the provider with the SDK, so the + // active provider (last bound) wins. self.spv_context_provider .read() .map_err(|_| "SPV provider lock poisoned".to_string())? @@ -577,13 +567,6 @@ impl AppContext { .read() .map_err(|_| "RPC provider lock poisoned".to_string())? .bind_app_context(self.clone())?; - } else { - let provider = self - .spv_context_provider - .read() - .map_err(|_| "SPV provider lock poisoned".to_string())? - .clone(); - self.sdk.load().set_context_provider(provider); } Ok(()) diff --git a/src/context_provider.rs b/src/context_provider.rs index 76df9c10b..d84cff60a 100644 --- a/src/context_provider.rs +++ b/src/context_provider.rs @@ -7,11 +7,64 @@ use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::version::PlatformVersion; use dash_sdk::error::ContextProviderError; -use dash_sdk::platform::ContextProvider; -use dash_sdk::platform::DataContract; +use dash_sdk::platform::{ContextProvider, DataContract, Identifier}; use rusqlite::Result; use std::sync::{Arc, Mutex}; +// --------------------------------------------------------------------------- +// Shared contract/token resolution used by both RPC and SPV providers. +// --------------------------------------------------------------------------- + +/// Number of system contracts cached on [`AppContext`]. +/// Update this when adding a new system contract field. +/// +/// The typed array size in [`resolve_data_contract`] must match — the compiler +/// will reject a mismatch, catching forgotten additions at build time. +pub(crate) const SYSTEM_CONTRACT_COUNT: usize = 5; + +/// Resolve a data contract by ID: check cached system contracts first, then DB. +/// +/// All system contracts are listed in `cached` — adding a new one is a single +/// array edit, which prevents the two providers from drifting out of sync. +/// The array size is tied to [`SYSTEM_CONTRACT_COUNT`] so the compiler enforces +/// completeness. +pub(crate) fn resolve_data_contract( + app_ctx: &AppContext, + db: &Database, + data_contract_id: &Identifier, +) -> Result>, ContextProviderError> { + let cached: [&Arc; SYSTEM_CONTRACT_COUNT] = [ + &app_ctx.dpns_contract, + &app_ctx.dashpay_contract, + &app_ctx.token_history_contract, + &app_ctx.withdraws_contract, + &app_ctx.keyword_search_contract, + ]; + + for contract in &cached { + if data_contract_id == &contract.id() { + return Ok(Some(Arc::clone(contract))); + } + } + + // DB fallback for user-added / non-system contracts + let dc = db + .get_contract_by_id(*data_contract_id, app_ctx) + .map_err(|e| ContextProviderError::Generic(e.to_string()))?; + + Ok(dc.map(|qc| Arc::new(qc.contract))) +} + +/// Resolve a token configuration from the database. +pub(crate) fn resolve_token_configuration( + app_ctx: &AppContext, + db: &Database, + token_id: &Identifier, +) -> Result, ContextProviderError> { + db.get_token_config_for_id(token_id, app_ctx) + .map_err(|e| ContextProviderError::Generic(e.to_string())) +} + pub(crate) struct Provider { db: Arc, app_context: Mutex>>, @@ -21,7 +74,7 @@ pub(crate) struct Provider { impl Provider { /// Create new ContextProvider. /// - /// Note that you have to bind it to app context using [Provider::set_app_context()]. + /// Note that you have to bind it to app context using [`Provider::bind_app_context`]. pub fn new( db: Arc, network: Network, @@ -78,55 +131,36 @@ impl Provider { impl ContextProvider for Provider { fn get_data_contract( &self, - data_contract_id: &dash_sdk::platform::Identifier, + data_contract_id: &Identifier, _platform_version: &PlatformVersion, - ) -> Result>, dash_sdk::error::ContextProviderError> { - let app_ctx_guard = self + ) -> Result>, ContextProviderError> { + let guard = self .app_context .lock() - .map_err(|_| ContextProviderError::Config("Provider lock poisoned".to_string()))?; - let app_ctx = app_ctx_guard + .map_err(|_| ContextProviderError::Config("RpcProvider lock poisoned".to_string()))?; + let app_ctx = guard .as_ref() - .ok_or(ContextProviderError::Config("no app context".to_string()))?; - - if data_contract_id == &app_ctx.dpns_contract.id() { - Ok(Some(app_ctx.dpns_contract.clone())) - } else if data_contract_id == &app_ctx.dashpay_contract.id() { - Ok(Some(app_ctx.dashpay_contract.clone())) - } else if data_contract_id == &app_ctx.token_history_contract.id() { - Ok(Some(app_ctx.token_history_contract.clone())) - } else if data_contract_id == &app_ctx.withdraws_contract.id() { - Ok(Some(app_ctx.withdraws_contract.clone())) - } else if data_contract_id == &app_ctx.keyword_search_contract.id() { - Ok(Some(app_ctx.keyword_search_contract.clone())) - } else { - let dc = self - .db - .get_contract_by_id(*data_contract_id, app_ctx.as_ref()) - .map_err(|e| dash_sdk::error::ContextProviderError::Generic(e.to_string()))?; - - drop(app_ctx_guard); - - Ok(dc.map(|qc| Arc::new(qc.contract))) - } + .ok_or(ContextProviderError::Config("no app context".to_string()))? + .clone(); + drop(guard); + resolve_data_contract(&app_ctx, &self.db, data_contract_id) } fn get_token_configuration( &self, - token_id: &dash_sdk::platform::Identifier, + token_id: &Identifier, ) -> Result, ContextProviderError> { - let app_ctx_guard = self + let guard = self .app_context .lock() - .map_err(|_| ContextProviderError::Config("Provider lock poisoned".to_string()))?; - let app_ctx = app_ctx_guard + .map_err(|_| ContextProviderError::Config("RpcProvider lock poisoned".to_string()))?; + let app_ctx = guard .as_ref() - .ok_or(ContextProviderError::Config("no app context".to_string()))?; - - self.db - .get_token_config_for_id(token_id, app_ctx) - .map_err(|e| dash_sdk::error::ContextProviderError::Generic(e.to_string())) + .ok_or(ContextProviderError::Config("no app context".to_string()))? + .clone(); + drop(guard); + resolve_token_configuration(&app_ctx, &self.db, token_id) } fn get_quorum_public_key( diff --git a/src/context_provider_spv.rs b/src/context_provider_spv.rs index a75555269..1479368f4 100644 --- a/src/context_provider_spv.rs +++ b/src/context_provider_spv.rs @@ -1,10 +1,10 @@ use crate::context::AppContext; +use crate::context_provider::{resolve_data_contract, resolve_token_configuration}; use crate::database::Database; use dash_sdk::dpp::dashcore::Network; -use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::version::PlatformVersion; use dash_sdk::error::ContextProviderError; -use dash_sdk::platform::{ContextProvider, DataContract}; +use dash_sdk::platform::{ContextProvider, DataContract, Identifier}; use std::sync::{Arc, Mutex}; /// SPV-based ContextProvider for the Dash SDK. @@ -27,15 +27,26 @@ impl SpvProvider { }) } - /// Attach the `AppContext` so we can access SpvManager and settings. + /// Attach the `AppContext` and register this provider with the SDK. + /// + /// Mirrors [`Provider::bind_app_context`](crate::context_provider::Provider::bind_app_context) + /// — after this call, the SDK + /// uses this provider for proof verification and quorum key resolution. /// /// Returns an error if the lock is poisoned (indicates a prior panic). + /// + /// # Thread safety + /// Called during init and mode-switch only — not on hot paths. pub fn bind_app_context(&self, app_context: Arc) -> Result<(), String> { + let cloned = app_context.clone(); let mut ac = self .app_context .lock() .map_err(|_| "SpvProvider app_context lock poisoned".to_string())?; - ac.replace(app_context); + ac.replace(cloned); + drop(ac); + + app_context.sdk.load().set_context_provider(self.clone()); Ok(()) } } @@ -43,53 +54,36 @@ impl SpvProvider { impl ContextProvider for SpvProvider { fn get_data_contract( &self, - data_contract_id: &dash_sdk::platform::Identifier, + data_contract_id: &Identifier, _platform_version: &PlatformVersion, ) -> Result>, ContextProviderError> { - let app_ctx_guard = self + let guard = self .app_context .lock() .map_err(|_| ContextProviderError::Config("SpvProvider lock poisoned".to_string()))?; - let app_ctx = app_ctx_guard + let app_ctx = guard .as_ref() - .ok_or(ContextProviderError::Config("no app context".to_string()))?; - - if data_contract_id == &app_ctx.dpns_contract.id() { - Ok(Some(app_ctx.dpns_contract.clone())) - } else if data_contract_id == &app_ctx.token_history_contract.id() { - Ok(Some(app_ctx.token_history_contract.clone())) - } else if data_contract_id == &app_ctx.withdraws_contract.id() { - Ok(Some(app_ctx.withdraws_contract.clone())) - } else if data_contract_id == &app_ctx.keyword_search_contract.id() { - Ok(Some(app_ctx.keyword_search_contract.clone())) - } else { - let dc = self - .db - .get_contract_by_id(*data_contract_id, app_ctx.as_ref()) - .map_err(|e| ContextProviderError::Generic(e.to_string()))?; - - drop(app_ctx_guard); - - Ok(dc.map(|qc| Arc::new(qc.contract))) - } + .ok_or(ContextProviderError::Config("no app context".to_string()))? + .clone(); + drop(guard); + resolve_data_contract(&app_ctx, &self.db, data_contract_id) } fn get_token_configuration( &self, - token_id: &dash_sdk::platform::Identifier, + token_id: &Identifier, ) -> Result, ContextProviderError> { - let app_ctx_guard = self + let guard = self .app_context .lock() .map_err(|_| ContextProviderError::Config("SpvProvider lock poisoned".to_string()))?; - let app_ctx = app_ctx_guard + let app_ctx = guard .as_ref() - .ok_or(ContextProviderError::Config("no app context".to_string()))?; - - self.db - .get_token_config_for_id(token_id, app_ctx) - .map_err(|e| ContextProviderError::Generic(e.to_string())) + .ok_or(ContextProviderError::Config("no app context".to_string()))? + .clone(); + drop(guard); + resolve_token_configuration(&app_ctx, &self.db, token_id) } fn get_quorum_public_key( diff --git a/src/database/mod.rs b/src/database/mod.rs index eea644113..9902c2ce5 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -16,6 +16,7 @@ mod tokens; mod top_ups; mod utxo; mod wallet; +pub use wallet::WalletError; use dash_sdk::dpp::dashcore::Network; use rusqlite::{Connection, Params}; diff --git a/src/logging.rs b/src/logging.rs index 0793ceb14..e3e3bf9f9 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -1,4 +1,7 @@ use crate::{VERSION, app_dir::app_user_data_file_path}; +use chrono::{Duration, Local}; +use std::backtrace::Backtrace; +use std::fs; use std::panic; use std::sync::Once; use tracing::{error, info}; @@ -13,6 +16,8 @@ pub fn initialize_logger() { } fn initialize_logger_internal() { + rotate_log_file(); + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| { EnvFilter::try_new( "info,dash_evo_tool=trace,dash_sdk=debug,dash_sdk::platform::transition=trace,tenderdash_abci=debug,drive=debug,drive_proof_verifier=debug,rs_dapi_client=debug,h2=warn,dash_spv=debug", @@ -73,9 +78,11 @@ fn initialize_logger_internal() { .location() .unwrap_or_else(|| panic::Location::caller()); + let backtrace = Backtrace::force_capture(); + error!( location = tracing::field::display(location), - "Panic occurred: {}", message + "Panic occurred: {}\n{}", message, backtrace ); default_panic_hook(panic_info); @@ -94,3 +101,43 @@ fn initialize_logger_internal() { ); } } + +fn rotate_log_file() { + let Ok(log_path) = app_user_data_file_path("det.log") else { + return; + }; + if log_path.exists() { + let ts = fs::metadata(&log_path) + .and_then(|m| m.modified()) + .map(chrono::DateTime::::from) + .unwrap_or_else(|_| Local::now()) + .timestamp(); + let rotated = log_path.with_file_name(format!("det.{ts:010}.log")); + let _ = fs::rename(&log_path, rotated); + } + + let Some(parent) = log_path.parent() else { + return; + }; + let cutoff = (Local::now() - Duration::days(7)).timestamp(); + let Ok(entries) = fs::read_dir(parent) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + let Some(ts_str) = name + .strip_prefix("det.") + .and_then(|s| s.strip_suffix(".log")) + else { + continue; + }; + if let Ok(ts) = ts_str.parse::() + && ts < cutoff + { + let _ = fs::remove_file(path); + } + } +} diff --git a/src/main.rs b/src/main.rs index c5a65f6c1..5c07da0a7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -55,6 +55,9 @@ async fn start(app_data_dir: &std::path::Path) -> Result<(), eframe::Error> { viewport: egui::ViewportBuilder::default() .with_icon(icon_data) .with_app_id("org.dash.DashEvoTool"), + // Use wgpu instead of glow (OpenGL) to avoid platform-specific rendering + // issues, e.g. NSOpenGLContext idle/sleep crashes on macOS (#629) + renderer: eframe::Renderer::Wgpu, ..Default::default() }; diff --git a/src/spv/manager.rs b/src/spv/manager.rs index a2e9ddcd3..34d563b84 100644 --- a/src/spv/manager.rs +++ b/src/spv/manager.rs @@ -3,12 +3,14 @@ use crate::app_dir::app_user_data_dir_path; use crate::config::NetworkConfig; use crate::model::wallet::WalletSeedHash; use crate::utils::tasks::TaskManager; -use dash_sdk::dash_spv::client::interface::{DashSpvClientCommand, DashSpvClientInterface}; +use arc_swap::ArcSwapOption; +use dash_sdk::dash_spv::client::interface::DashSpvClientCommand; use dash_sdk::dash_spv::network::NetworkEvent; use dash_sdk::dash_spv::network::PeerNetworkManager; use dash_sdk::dash_spv::storage::DiskStorageManager; use dash_sdk::dash_spv::sync::SyncEvent; use dash_sdk::dash_spv::sync::SyncProgress as SpvSyncProgress; +use dash_sdk::dash_spv::sync::SyncState; use dash_sdk::dash_spv::types::ValidationMode; use dash_sdk::dash_spv::{ClientConfig, DashSpvClient, Hash, LLMQType, QuorumHash}; use dash_sdk::dpp::dashcore::{Address, InstantLock, Network, Transaction, Txid}; @@ -21,7 +23,6 @@ use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::{ use dash_sdk::dpp::key_wallet_manager::WalletEvent; use dash_sdk::dpp::key_wallet_manager::wallet_interface::WalletInterface; use dash_sdk::dpp::key_wallet_manager::wallet_manager::{WalletError, WalletId, WalletManager}; -// use dash_sdk::dpp::key_wallet::bip32::ExtendedPubKey; // not needed directly here use std::fmt; use std::fs; use std::net::ToSocketAddrs; @@ -149,8 +150,9 @@ pub struct SpvManager { wallet: Arc>>, // Storage manager for direct access to SPV data (shared component from client) storage: Arc>>>>, - // Interface for sending commands to the running SPV client (quorum lookups, etc.) - client_interface: Arc>>, + // Shared reference to the running SPV client (for quorum lookups, etc.) + // ArcSwapOption gives wait-free reads (quorum lookups) and atomic set/clear on start/stop. + spv_client: ArcSwapOption, status: Arc>, last_error: Arc>>, started_at: Arc>>, @@ -297,7 +299,7 @@ impl SpvManager { network, ))), storage: Arc::new(Mutex::new(None)), - client_interface: Arc::new(RwLock::new(None)), + spv_client: ArcSwapOption::empty(), status: Arc::new(RwLock::new(SpvStatus::Idle)), last_error: Arc::new(RwLock::new(None)), started_at: Arc::new(RwLock::new(None)), @@ -551,9 +553,7 @@ impl SpvManager { *storage_guard = None; } - if let Ok(mut interface_guard) = self.client_interface.write() { - *interface_guard = None; - } + // spv_client is cleared asynchronously when the client stops; no action needed here. if let Ok(mut request_guard) = self.request_tx.lock() { *request_guard = None; @@ -606,8 +606,8 @@ impl SpvManager { /// Attempt to resolve a quorum public key via the SPV client's masternode/quorum state. /// - /// This method sends a request through the DashSpvClientInterface to query the running - /// SPV client. If SPV is not running or the key is not known, an error is returned. + /// Queries the running SPV client directly. If SPV is not running or the key is not + /// known, an error is returned. pub fn get_quorum_public_key( &self, quorum_type: u32, @@ -621,16 +621,6 @@ impl SpvManager { core_chain_locked_height ); - let interface = { - let guard = self - .client_interface - .read() - .map_err(|e| format!("client_interface lock poisoned: {e}"))?; - guard - .clone() - .ok_or_else(|| "SPV client not initialized".to_string())? - }; - let llmq_type = LLMQType::from(quorum_type as u8); let qh = QuorumHash::from_byte_array(quorum_hash).reverse(); @@ -641,10 +631,15 @@ impl SpvManager { core_chain_locked_height ); + let client = self + .spv_client + .load_full() + .ok_or_else(|| "SPV client not initialized".to_string())?; + tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async { - interface - .get_quorum_by_height(core_chain_locked_height, llmq_type, qh) + client + .get_quorum_at_height(core_chain_locked_height, llmq_type, qh) .await .map(|q| { tracing::debug!( @@ -825,13 +820,9 @@ impl SpvManager { } } - // Build and start the client + // Build the client and wrap in Arc for shared access let has_wallets = expected_wallet_count > 0; - let mut client = self.build_client(has_wallets).await?; - client - .start() - .await - .map_err(|e| format!("SPV start failed: {e}"))?; + let client = Arc::new(self.build_client(has_wallets).await?); // Store the shared storage reference for later access { @@ -841,6 +832,9 @@ impl SpvManager { } } + // Store the client reference for quorum lookups (wait-free reads via ArcSwap) + self.spv_client.store(Some(Arc::clone(&client))); + // Subscribe to sync events (broadcast) let sync_rx = client.subscribe_sync_events(); self.spawn_sync_event_handler(sync_rx); @@ -871,35 +865,13 @@ impl SpvManager { // Spawn request handler in a separate task self.spawn_request_handler(request_rx, stop_token.clone()); - // Create command channel for the DashSpvClientInterface - // Note: Unbounded channel is required by SDK's DashSpvClientInterface API. - // Memory usage is bounded in practice by SPV command processing speed. - let (command_tx, command_receiver) = tokio::sync::mpsc::unbounded_channel(); - - // Store the interface for external queries (quorum lookups, etc.) - { - let interface = DashSpvClientInterface::new(command_tx); - let mut guard = self - .client_interface - .write() - .map_err(|e| format!("client_interface lock poisoned: {e}"))?; - *guard = Some(interface); - } - let _ = self.write_status(SpvStatus::Syncing); - // Run sync and monitor with the client owned in this scope - let result = self - .clone() - .run_sync_and_monitor(client, command_receiver, stop_token) - .await; + // Run the client — handles start, monitoring, and stop internally + let result = self.clone().run_client(client, stop_token).await; - // Clear the interface and network manager since the client is done - { - if let Ok(mut guard) = self.client_interface.write() { - *guard = None; - } - } + // Clear the client reference and network manager since the client is done + self.spv_client.store(None); { let mut nm_guard = self.network_manager.write().await; *nm_guard = None; @@ -920,69 +892,39 @@ impl SpvManager { result } - async fn run_sync_and_monitor( + async fn run_client( self: Arc, - mut client: SpvClient, - command_receiver: mpsc::UnboundedReceiver, + client: Arc, stop_token: CancellationToken, ) -> Result<(), String> { - // Monitor network continuously - this handles initial sync and ongoing monitoring - // Requests are handled through the DashSpvClientInterface command channel - enum Outcome { - MonitorCompleted(Result<(), dash_sdk::dash_spv::SpvError>), - Cancelled, - } + // Create command channel for DashSpvClient (used for quorum lookups etc.) + let (_command_tx, command_rx) = + tokio::sync::mpsc::unbounded_channel::(); - let outcome = { - let monitor_cancel = CancellationToken::new(); - let monitor_future = client.monitor_network(command_receiver, monitor_cancel.clone()); - tokio::pin!(monitor_future); - - // stop_token is a child of global_cancel, so it fires on either - // explicit SpvManager::stop() or application-wide shutdown. - tokio::select! { - result = &mut monitor_future => Outcome::MonitorCompleted(result), - _ = stop_token.cancelled() => { - monitor_cancel.cancel(); - Outcome::Cancelled - }, - } - }; // monitor_future is dropped here, releasing the mutable borrow + // client.run() takes ownership (mut self), so unwrap the Arc. + let client = Arc::try_unwrap(client) + .map_err(|_| "Cannot unwrap SpvClient Arc: other references still held".to_string())?; - tracing::info!( - "run_sync_and_monitor: outcome = {}", - match &outcome { - Outcome::MonitorCompleted(Ok(())) => "MonitorCompleted(Ok)", - Outcome::MonitorCompleted(Err(_)) => "MonitorCompleted(Err)", - Outcome::Cancelled => "Cancelled", - } - ); + let result = client + .run(command_rx, stop_token) + .await + .map_err(|e| format!("SPV client error: {e}")); - // Stop the client after monitoring completes or is cancelled - tracing::info!("run_sync_and_monitor: calling client.stop()..."); - let stop_start = std::time::Instant::now(); - let _ = client.stop().await; tracing::info!( - "run_sync_and_monitor: client.stop() took {:?}", - stop_start.elapsed() + "run_client: outcome = {}", + if result.is_ok() { "Ok" } else { "Err" } ); - match outcome { - Outcome::MonitorCompleted(Ok(())) => { + match &result { + Ok(()) => { let _ = self.write_status(SpvStatus::Stopped); - Ok(()) } - Outcome::MonitorCompleted(Err(err)) => { - let message = format!("monitor_network failed: {err}"); + Err(message) => { let _ = self.write_last_error(Some(message.clone())); let _ = self.write_status(SpvStatus::Error); - Err(message) - } - Outcome::Cancelled => { - let _ = self.write_status(SpvStatus::Stopped); - Ok(()) } } + result } fn spawn_request_handler( @@ -992,6 +934,10 @@ impl SpvManager { ) { tracing::info!("SPV request handler started"); let network_manager = Arc::clone(&self.network_manager); + // TODO(workaround): Remove wallet + reconcile_tx captures once + // dashpay/rust-dashcore#487 is fixed upstream. + let wallet = Arc::clone(&self.wallet); + let reconcile_tx = self.reconcile_tx.lock().ok().and_then(|g| g.clone()); self.subtasks.spawn_sync("spv_request_handler", async move { loop { tokio::select! { @@ -1030,6 +976,19 @@ impl SpvManager { Err("SPV network manager not available".to_string()) } }; + // TODO(workaround): Remove once dashpay/rust-dashcore#487 + // is fixed. Upstream broadcast does not call + // process_mempool_transaction(), so the wallet doesn't + // know about its own outgoing tx until a block is mined. + if result.is_ok() { + notify_wallet_after_broadcast( + &wallet, + &tx, + reconcile_tx.as_ref(), + ) + .await; + } + let _ = response_tx.send(result); } None => { @@ -1044,11 +1003,49 @@ impl SpvManager { }); } + /// Identify which sync manager phase is in Error state, if any. + /// Checks masternodes first as the most common failure point, + /// rather than pipeline execution order used by `spv_phase_summary()`. + fn failed_manager_name(progress: &SpvSyncProgress) -> &'static str { + if progress + .masternodes() + .is_ok_and(|p| p.state() == SyncState::Error) + { + return "Masternodes"; + } + if progress + .headers() + .is_ok_and(|p| p.state() == SyncState::Error) + { + return "Headers"; + } + if progress + .filter_headers() + .is_ok_and(|p| p.state() == SyncState::Error) + { + return "Filter headers"; + } + if progress + .filters() + .is_ok_and(|p| p.state() == SyncState::Error) + { + return "Filters"; + } + if progress + .blocks() + .is_ok_and(|p| p.state() == SyncState::Error) + { + return "Blocks"; + } + "unknown phase" + } + fn spawn_progress_watcher( &self, mut progress_rx: tokio::sync::watch::Receiver, ) { let status = Arc::clone(&self.status); + let last_error = Arc::clone(&self.last_error); let sync_progress_state = Arc::clone(&self.sync_progress_state); let progress_updated_at = Arc::clone(&self.progress_updated_at); let cancel = self.subtasks.cancellation_token.clone(); @@ -1063,6 +1060,12 @@ impl SpvManager { } let watch_progress = progress_rx.borrow().clone(); let is_synced = watch_progress.is_synced(); + let is_error = watch_progress.state() == SyncState::Error; + let failed_phase = if is_error { + Some(Self::failed_manager_name(&watch_progress)) + } else { + None + }; // Update sync progress state if let Ok(mut stored_sync) = sync_progress_state.write() { @@ -1076,10 +1079,27 @@ impl SpvManager { if let Ok(mut status_guard) = status.write() { if is_synced { *status_guard = SpvStatus::Running; + } else if is_error { + *status_guard = SpvStatus::Error; } else if !matches!(*status_guard, SpvStatus::Stopping | SpvStatus::Stopped | SpvStatus::Error) { *status_guard = SpvStatus::Syncing; } } + // Write last_error outside status lock to maintain + // consistent lock ordering (status → release → last_error). + if is_error + && let Ok(mut err_guard) = last_error.write() + && err_guard.is_none() + { + // Note: this path is currently unreachable due to upstream + // bug dashpay/rust-dashcore#469 (progress channel never + // receives SyncState::Error). Once fixed, this will fire. + let phase = failed_phase.unwrap_or("unknown phase"); + *err_guard = Some(format!( + "Sync failed: {} (reported by SPV progress channel)", + phase + )); + } } } } @@ -1091,6 +1111,7 @@ impl SpvManager { let reconcile_tx = self.reconcile_tx.lock().ok().and_then(|g| g.clone()); let finality_tx = self.finality_tx.lock().ok().and_then(|g| g.clone()); let status = Arc::clone(&self.status); + let last_error = Arc::clone(&self.last_error); let cancel = self.subtasks.cancellation_token.clone(); self.subtasks.spawn_sync("spv_sync_event_handler", async move { @@ -1135,6 +1156,31 @@ impl SpvManager { { *guard = SpvStatus::Running; } + + // Transition to Error when a sync manager reports a + // fatal failure. The dash-spv library emits this event + // but does NOT update the progress channel on the error + // path, so we must react to the event directly. + if let SyncEvent::ManagerError { ref manager, ref error } = event { + tracing::error!("SPV manager {} reported error: {}", manager, error); + if let Ok(mut guard) = status.write() { + *guard = SpvStatus::Error; + drop(guard); // Maintain lock ordering: status → release → last_error + } + + // Truncate error before formatting to avoid + // large transient allocations from adversarial peers. + let limit = error.floor_char_boundary(100); + let msg = format!("Sync manager {} failed: {}", manager, &error[..limit]); + if let Ok(mut err_guard) = last_error.write() { + if err_guard.is_none() { + *err_guard = Some(msg); + } else { + tracing::warn!(%manager, error, "SPV last_error already set, ignoring subsequent: {}", msg); + } + } + } + if should_signal && let Some(ref tx) = reconcile_tx { @@ -1343,6 +1389,28 @@ fn build_spv_data_dir(network: Network, config: &NetworkConfig) -> Result>>, + tx: &Transaction, + reconcile_tx: Option<&mpsc::Sender<()>>, +) { + { + let mut wm = wallet.write().await; + wm.process_mempool_transaction(tx).await; + } + if let Some(ch) = reconcile_tx { + let _ = ch.try_send(()); + } + tracing::debug!("Notified wallet about broadcast tx {}", tx.txid()); +} + impl fmt::Debug for SpvManager { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("SpvManager") diff --git a/src/ui/components/message_banner.rs b/src/ui/components/message_banner.rs index b3fe7e6aa..015bcb109 100644 --- a/src/ui/components/message_banner.rs +++ b/src/ui/components/message_banner.rs @@ -2,6 +2,7 @@ use crate::ui::MessageType; use crate::ui::components::component_trait::{Component, ComponentResponse}; use crate::ui::theme::{DashColors, Shape, Spacing, Typography}; use egui::InnerResponse; +use std::fmt; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, Instant}; use tracing::{debug, error, warn}; @@ -16,6 +17,9 @@ const DETAILS_MAX_HEIGHT: f32 = 120.0; static BANNER_KEY_COUNTER: AtomicU64 = AtomicU64::new(0); fn next_banner_key() -> u64 { + // Relaxed is sufficient: we only need uniqueness (monotonic counter), + // not ordering with other atomic operations. The counter runs in a + // single-threaded UI context. BANNER_KEY_COUNTER.fetch_add(1, Ordering::Relaxed) } @@ -129,6 +133,10 @@ impl BannerState { /// /// The handle is `'static` and safe to store. Methods that modify the banner /// (`set_message`, `with_auto_dismiss`) take `&self` so the handle can be reused. +/// +/// INTENTIONAL(SEC-004): BannerHandle is Send+Sync because egui::Context is +/// Send+Sync with internal locking. This is acceptable for a single-threaded +/// UI app; egui's own thread-safety guarantees apply. #[derive(Clone)] pub struct BannerHandle { ctx: egui::Context, @@ -148,7 +156,7 @@ impl BannerHandle { /// Update the display text of this banner. /// Returns `None` if the banner no longer exists. - pub fn set_message(&self, text: &str) -> Option<&Self> { + pub fn set_message(&self, text: impl fmt::Display) -> Option<&Self> { let mut banners = get_banners(&self.ctx); let b = banners.iter_mut().find(|b| b.key == self.key)?; b.text = text.to_string(); @@ -182,14 +190,34 @@ impl BannerHandle { /// Attach optional technical details to this banner. /// Details are shown in a collapsible section (collapsed by default). + /// + /// Accepts `impl Debug` (not `Display`) because callers typically pass + /// error types whose `Debug` representation includes structured context + /// (nested causes, variant names) that is more useful in a diagnostic + /// details pane than the single-line `Display` output. + /// + /// INTENTIONAL(RUST-003): When plain strings are passed, `{:?}` wraps them + /// in quotes. This is acceptable since `with_details` is primarily for + /// error types, not user-facing text. + /// /// Returns `None` if the banner no longer exists. - pub fn with_details(&self, details: &str) -> Option<&Self> { + pub fn with_details(&self, details: impl fmt::Debug) -> Option<&Self> { + let details = format!("{:?}", details); if details.is_empty() { return Some(self); } let mut banners = get_banners(&self.ctx); let b = banners.iter_mut().find(|b| b.key == self.key)?; - b.details = Some(details.to_string()); + // Skip if details would just repeat the primary text (exact match or + // Debug-quoted match, e.g. `"same text"` vs `same text`). + let is_redundant = details == b.text + || details.strip_prefix('"').and_then(|s| s.strip_suffix('"')) == Some(b.text.as_str()); + + if is_redundant { + b.details = None; + } else { + b.details = Some(details); + } set_banners(&self.ctx, banners); Some(self) } @@ -197,13 +225,14 @@ impl BannerHandle { /// Attach an optional recovery suggestion to this banner. /// The suggestion is shown inline (visible without expanding). /// Returns `None` if the banner no longer exists. - pub fn with_suggestion(&self, suggestion: &str) -> Option<&Self> { + pub fn with_suggestion(&self, suggestion: impl fmt::Display) -> Option<&Self> { + let suggestion = suggestion.to_string(); if suggestion.is_empty() { return Some(self); } let mut banners = get_banners(&self.ctx); let b = banners.iter_mut().find(|b| b.key == self.key)?; - b.suggestion = Some(suggestion.to_string()); + b.suggestion = Some(suggestion); set_banners(&self.ctx, banners); Some(self) } @@ -236,15 +265,12 @@ impl MessageBanner { /// Sets or replaces the current message. Resets the auto-dismiss timer. /// An empty string is treated as a clear operation. - pub fn set_message(&mut self, text: &str, message_type: MessageType) -> &mut Self { + pub fn set_message(&mut self, text: impl fmt::Display, message_type: MessageType) -> &mut Self { + let text = text.to_string(); if text.is_empty() { self.state = None; } else { - self.state = Some(BannerState::new( - next_banner_key(), - text.to_string(), - message_type, - )); + self.state = Some(BannerState::new(next_banner_key(), text, message_type)); } self } @@ -279,25 +305,53 @@ impl MessageBanner { // for another (e.g., replacing a generic "Success" with a specific one). /// Adds a global banner message if one with the same text does not already exist. + /// + /// **Idempotent**: if a banner with identical text is already displayed, + /// this is a no-op and the existing banner is returned unchanged + /// (timestamps, auto-dismiss timer, and `logged` flag are all preserved). + /// This makes it safe to call every frame without side-effects. + /// + /// To reset the auto-dismiss timer of an existing banner, use + /// [`replace_global`](Self::replace_global) with the same text for both + /// `old_text` and `new_text`, or store the returned [`BannerHandle`] and + /// call [`BannerHandle::with_auto_dismiss`]. + /// /// Evicts the oldest message when the cap ([`MAX_BANNERS`]) is reached. /// /// Returns a [`BannerHandle`] for updating or clearing the banner later. - pub fn set_global(ctx: &egui::Context, text: &str, message_type: MessageType) -> BannerHandle { + pub fn set_global( + ctx: &egui::Context, + text: impl fmt::Display, + message_type: MessageType, + ) -> BannerHandle { + let text = text.to_string(); let mut banners = get_banners(ctx); if let Some(existing) = banners.iter_mut().find(|b| b.text == text) { - existing.reset_to(text.to_string(), message_type); - let key = existing.key; - set_banners(ctx, banners); + // Same text already displayed: update message_type if it changed, + // but preserve timestamps and auto-dismiss timer (idempotent for text). + if existing.message_type != message_type { + existing.message_type = message_type; + let key = existing.key; + set_banners(ctx, banners); + return BannerHandle { + ctx: ctx.clone(), + key, + }; + } return BannerHandle { ctx: ctx.clone(), - key, + key: existing.key, }; } let key = next_banner_key(); if !text.is_empty() { - banners.push(BannerState::new(key, text.to_string(), message_type)); + banners.push(BannerState::new(key, text, message_type)); if banners.len() > MAX_BANNERS { - banners.remove(0); + let evicted = banners.remove(0); + warn!( + "Banner evicted (capacity {}): {:?}", + MAX_BANNERS, evicted.message_type, + ); } set_banners(ctx, banners); } @@ -308,17 +362,31 @@ impl MessageBanner { } /// Finds a message by `old_text` and replaces it with `new_text`. - /// If `old_text` is not found, adds `new_text` as a new message (with dedup check). + /// If `old_text` is not found, falls back to adding `new_text` as a new + /// message (with dedup check). This fallback is intentional: callers use + /// `replace_global` for progress updates where the previous banner may + /// have been dismissed or evicted, and the new message should still appear. + /// + /// If `old_text` is not found but `new_text` is already displayed, returns + /// a handle to the existing banner without resetting it (consistent with + /// [`Self::set_global`] idempotency). + /// + /// **Empty `new_text`**: clears the `old_text` banner (if present) and + /// returns a handle with a fresh key that does not correspond to any banner. + /// Subsequent calls on this handle (`set_message`, `with_details`, `clear`) + /// are safe no-ops returning `None`. /// /// Returns a [`BannerHandle`] for updating or clearing the banner later. pub fn replace_global( ctx: &egui::Context, - old_text: &str, - new_text: &str, + old_text: impl fmt::Display, + new_text: impl fmt::Display, message_type: MessageType, ) -> BannerHandle { + let old_text = old_text.to_string(); + let new_text = new_text.to_string(); if new_text.is_empty() { - Self::clear_global_message(ctx, old_text); + Self::clear_global_message(ctx, &old_text); return BannerHandle { ctx: ctx.clone(), key: next_banner_key(), @@ -328,15 +396,20 @@ impl MessageBanner { let key; if let Some(b) = banners.iter_mut().find(|b| b.text == old_text) { key = b.key; - b.reset_to(new_text.to_string(), message_type); - } else if let Some(existing) = banners.iter_mut().find(|b| b.text == new_text) { + b.reset_to(new_text, message_type); + } else if let Some(existing) = banners.iter().find(|b| b.text == new_text) { + // Idempotent: if new_text already displayed, return handle without + // resetting (consistent with set_global behavior). key = existing.key; - existing.reset_to(new_text.to_string(), message_type); } else { key = next_banner_key(); - banners.push(BannerState::new(key, new_text.to_string(), message_type)); + banners.push(BannerState::new(key, new_text, message_type)); if banners.len() > MAX_BANNERS { - banners.remove(0); + let evicted = banners.remove(0); + warn!( + "Banner evicted (capacity {}): {:?}", + MAX_BANNERS, evicted.message_type, + ); } } set_banners(ctx, banners); @@ -347,12 +420,21 @@ impl MessageBanner { } /// Clears the specific global banner message matching `text`. - pub fn clear_global_message(ctx: &egui::Context, text: &str) { + pub fn clear_global_message(ctx: &egui::Context, text: impl fmt::Display) { + let text = text.to_string(); let mut banners = get_banners(ctx); banners.retain(|b| b.text != text); set_banners(ctx, banners); } + /// Clears all global banner messages. + /// + /// Use when the context changes significantly (e.g., network switch) and + /// stale messages from the previous context should not persist. + pub fn clear_all_global(ctx: &egui::Context) { + set_banners(ctx, vec![]); + } + /// Returns whether any global banner messages exist. #[allow(dead_code)] pub fn has_global(ctx: &egui::Context) -> bool { @@ -366,6 +448,8 @@ impl MessageBanner { if banners.is_empty() { return; } + // Always write back: process_banner() mutates state (auto-dismiss timers, + // expanded flags) even when no banners are removed. banners.retain_mut(|b| process_banner(ui, b) == BannerStatus::Visible); set_banners(ui.ctx(), banners); } @@ -464,6 +548,7 @@ fn process_banner(ui: &mut egui::Ui, state: &mut BannerState) -> BannerStatus { state.suggestion.as_deref(), state.details.as_deref(), &mut state.details_expanded, + state.key, ) { return BannerStatus::Dismissed; } @@ -475,6 +560,7 @@ fn process_banner(ui: &mut egui::Ui, state: &mut BannerState) -> BannerStatus { /// Shared rendering logic for both global and per-instance banners. /// Returns `true` if the dismiss button was clicked. +#[allow(clippy::too_many_arguments)] fn render_banner( ui: &mut egui::Ui, text: &str, @@ -483,6 +569,7 @@ fn render_banner( suggestion: Option<&str>, details: Option<&str>, details_expanded: &mut bool, + banner_key: u64, ) -> bool { let dark_mode = ui.ctx().style().visuals.dark_mode; let fg_color = DashColors::message_color(message_type, dark_mode); @@ -526,17 +613,16 @@ fn render_banner( // Right-aligned: annotation + dismiss ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| { - let dismiss_response = ui.add( - egui::Label::new( - egui::RichText::new("\u{274C}") - .color(fg_color) - .font(Typography::body_small()), + let dismiss_response = ui + .add( + egui::Label::new( + egui::RichText::new("\u{274C}") + .color(fg_color) + .font(Typography::body_small()), + ) + .sense(egui::Sense::click()), ) - .sense(egui::Sense::click()), - ); - if dismiss_response.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); - } + .on_hover_cursor(egui::CursorIcon::PointingHand); if dismiss_response.clicked() { dismissed = true; } @@ -573,7 +659,7 @@ fn render_banner( } else { "Show details" }; - if ui + let toggle_response = ui .add( egui::Label::new( egui::RichText::new(toggle_text) @@ -583,8 +669,8 @@ fn render_banner( ) .sense(egui::Sense::click()), ) - .clicked() - { + .on_hover_cursor(egui::CursorIcon::PointingHand); + if toggle_response.clicked() { *details_expanded = !*details_expanded; } @@ -596,6 +682,7 @@ fn render_banner( .corner_radius(Shape::RADIUS_SM as f32) .show(ui, |ui| { egui::ScrollArea::vertical() + .id_salt(banner_key) .max_height(DETAILS_MAX_HEIGHT) .show(ui, |ui| { ui.add( @@ -640,3 +727,105 @@ fn icon_for_type(message_type: MessageType) -> &'static str { MessageType::Info => "\u{1F4AC}", // speech balloon (💬) } } + +// --------------------------------------------------------------------------- +// Extension traits for ergonomic banner display on Result and Option. +// --------------------------------------------------------------------------- + +/// Extension for `Result` — show an error banner on `Err`, pass through unchanged. +/// +/// ```ignore +/// let wallet = get_selected_wallet(&identity, None, key) +/// .or_show_error(app_context.egui_ctx()) +/// .unwrap_or(None); +/// ``` +pub trait ResultBannerExt { + /// If `Err`, displays a global error banner with the error's `Display` text. + /// Returns `self` unchanged — this is a side-effect-only method. + /// + /// INTENTIONAL(SEC-007): Raw `Display` text is shown directly. Callers must + /// ensure error types have user-friendly Display implementations. + fn or_show_error(self, ctx: &egui::Context) -> Self; +} + +impl ResultBannerExt for Result { + fn or_show_error(self, ctx: &egui::Context) -> Self { + if let Err(ref e) = self { + MessageBanner::set_global(ctx, e, MessageType::Error); + } + self + } +} + +/// Extension for `Option` — show an error banner on `None`, pass through unchanged. +/// +/// ```ignore +/// let identity = identities.first().cloned() +/// .or_show_error(ctx, "No identities loaded"); +/// ``` +pub trait OptionBannerShowExt { + /// If `None`, displays a global error banner with the given message. + /// Returns `self` unchanged — this is a side-effect-only method. + fn or_show_error(self, ctx: &egui::Context, msg: impl fmt::Display) -> Self; +} + +impl OptionBannerShowExt for Option { + fn or_show_error(self, ctx: &egui::Context, msg: impl fmt::Display) -> Self { + if self.is_none() { + MessageBanner::set_global(ctx, msg, MessageType::Error); + } + self + } +} + +/// Extension for `Option` — banner lifecycle management. +/// +/// Screens that run backend tasks typically store a `refresh_banner: Option`. +/// This trait provides convenience methods to clear and/or replace that banner atomically. +/// +/// ```ignore +/// self.refresh_banner.take_and_clear(); +/// self.refresh_banner.replace(ctx, "Loading...", MessageType::Info); +/// self.refresh_banner.replace_with_elapsed(ctx, "Refreshing...", MessageType::Info); +/// ``` +pub trait OptionBannerExt { + /// Takes the handle (leaving `None`) and clears the associated banner. + fn take_and_clear(&mut self); + + /// Clears any existing banner, sets a new global banner, and stores the handle. + fn replace(&mut self, ctx: &egui::Context, msg: impl fmt::Display, msg_type: MessageType); + + /// Like [`replace`](OptionBannerExt::replace), but also enables elapsed-time display on + /// the new banner (useful for long-running operations). + fn replace_with_elapsed( + &mut self, + ctx: &egui::Context, + msg: impl fmt::Display, + msg_type: MessageType, + ); +} + +impl OptionBannerExt for Option { + fn take_and_clear(&mut self) { + if let Some(h) = self.take() { + h.clear(); + } + } + + fn replace(&mut self, ctx: &egui::Context, msg: impl fmt::Display, msg_type: MessageType) { + self.take_and_clear(); + *self = Some(MessageBanner::set_global(ctx, msg.to_string(), msg_type)); + } + + fn replace_with_elapsed( + &mut self, + ctx: &egui::Context, + msg: impl fmt::Display, + msg_type: MessageType, + ) { + self.take_and_clear(); + let handle = MessageBanner::set_global(ctx, msg.to_string(), msg_type); + handle.with_elapsed(); + *self = Some(handle); + } +} diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs index 1c71238c4..95dc26d8c 100644 --- a/src/ui/components/mod.rs +++ b/src/ui/components/mod.rs @@ -19,4 +19,7 @@ pub mod wallet_unlock_popup; // Re-export the main traits for easy access pub use component_trait::{Component, ComponentResponse}; -pub use message_banner::{BannerHandle, BannerStatus, MessageBanner, MessageBannerResponse}; +pub use message_banner::{ + BannerHandle, BannerStatus, MessageBanner, MessageBannerResponse, OptionBannerExt, + OptionBannerShowExt, ResultBannerExt, +}; diff --git a/src/ui/components/top_panel.rs b/src/ui/components/top_panel.rs index e022f1a59..a798a428b 100644 --- a/src/ui/components/top_panel.rs +++ b/src/ui/components/top_panel.rs @@ -6,7 +6,9 @@ use crate::context::connection_status::OverallConnectionState; use crate::spv::CoreBackendMode; use crate::ui::ScreenType; use crate::ui::theme::{DashColors, Shadow, Shape}; -use egui::{Context, Frame, Margin, RichText, Stroke, TextureHandle, TopBottomPanel, Ui}; +use egui::{ + Align2, Context, FontId, Frame, Margin, RichText, Stroke, TextureHandle, TopBottomPanel, Ui, +}; use rust_embed::RustEmbed; use std::sync::Arc; use tracing::error; @@ -104,19 +106,24 @@ fn add_connection_indicator(ui: &mut Ui, app_context: &Arc) -> AppAc let dark_mode = ui.ctx().style().visuals.dark_mode; let circle_size = 14.0; - // Three-state color: green (synced), orange (syncing), red (disconnected) + // Five-state color: green (synced), orange (syncing/connecting), magenta (error), red (disconnected) let color = match overall { OverallConnectionState::Synced => DashColors::success_color(dark_mode), - OverallConnectionState::Syncing => DashColors::warning_color(dark_mode), + OverallConnectionState::Connecting | OverallConnectionState::Syncing => { + DashColors::warning_color(dark_mode) + } + OverallConnectionState::Error => DashColors::sync_error_color(dark_mode), OverallConnectionState::Disconnected => DashColors::error_color(dark_mode), }; - // Pulsation: synced = normal pulse, syncing = slower pulse, disconnected = none + // Pulsation per state let time = ui.ctx().input(|i| i.time as f32); let pulse_scale = match overall { OverallConnectionState::Synced => 1.0 + 0.2 * (time * 2.0).sin(), + OverallConnectionState::Connecting => 1.0 + 0.2 * (time * 2.5).sin(), OverallConnectionState::Syncing => 1.0 + 0.15 * (time * 1.2).sin(), + OverallConnectionState::Error => 1.0 + 0.25 * (time * 0.8).sin(), OverallConnectionState::Disconnected => 1.0, // No pulse when disconnected }; @@ -147,7 +154,18 @@ fn add_connection_indicator(ui: &mut Ui, app_context: &Arc) -> AppAc // Draw the main circle ui.painter().circle_filled(center, circle_size / 2.0, color); - // Request repaint for animation (only when not disconnected) + // Draw "!" glyph on error state + if overall == OverallConnectionState::Error { + ui.painter().text( + center, + Align2::CENTER_CENTER, + "!", + FontId::proportional(10.0), + egui::Color32::WHITE, + ); + } + + // Request repaint for animation (Synced, Syncing, and Error states pulse) if overall != OverallConnectionState::Disconnected { app_context.repaint_animation(ui.ctx()); } diff --git a/src/ui/components/wallet_unlock.rs b/src/ui/components/wallet_unlock.rs index 67b695035..43aa7f5ad 100644 --- a/src/ui/components/wallet_unlock.rs +++ b/src/ui/components/wallet_unlock.rs @@ -1,8 +1,10 @@ use crate::context::AppContext; use crate::model::wallet::Wallet; +use crate::ui::MessageType; +use crate::ui::components::MessageBanner; use crate::ui::components::styled::StyledCheckbox; use crate::ui::theme::DashColors; -use egui::{Frame, Margin, RichText, Ui}; +use egui::Ui; use std::sync::{Arc, RwLock}; use zeroize::Zeroize; @@ -15,9 +17,6 @@ pub trait ScreenWithWalletUnlock { fn wallet_password_mut(&mut self) -> &mut String; fn show_password(&self) -> bool; fn show_password_mut(&mut self) -> &mut bool; - fn set_error_message(&mut self, error_message: Option); - - fn error_message(&self) -> Option<&String>; fn app_context(&self) -> Arc; @@ -26,7 +25,11 @@ pub trait ScreenWithWalletUnlock { let mut wallet = wallet_guard.write().unwrap(); if !wallet.uses_password { if let Err(e) = wallet.wallet_seed.open_no_password() { - self.set_error_message(Some(e)); + MessageBanner::set_global( + self.app_context().egui_ctx(), + &e, + MessageType::Error, + ); } false } else { @@ -66,9 +69,8 @@ pub trait ScreenWithWalletUnlock { // Capture necessary values before the closure let show_password = self.show_password(); - let mut local_show_password = show_password; // Local copy of show_password - let mut local_error_message = self.error_message().cloned(); // Local variable for error message - let wallet_password_mut = self.wallet_password_mut(); // Mutable reference to the password + let mut local_show_password = show_password; + let wallet_password_mut = self.wallet_password_mut(); let mut attempt_unlock = false; @@ -107,16 +109,16 @@ pub trait ScreenWithWalletUnlock { match unlock_result { Ok(_) => { - local_error_message = None; unlocked_wallet = Some(wallet_guard.clone()); } Err(_) => { - if let Some(hint) = wallet.password_hint() { - local_error_message = - Some(format!("Incorrect Password, password hint is {}", hint)); + let error_msg = if let Some(hint) = wallet.password_hint() { + format!("Incorrect Password, password hint is {}", hint) } else { - local_error_message = Some("Incorrect Password".to_string()); - } + "Incorrect Password".to_string() + }; + MessageBanner::set_global(ui.ctx(), &error_msg, MessageType::Error) + .with_auto_dismiss(std::time::Duration::from_secs(10)); } } // Clear the password field after submission @@ -125,32 +127,7 @@ pub trait ScreenWithWalletUnlock { // Update `show_password` after the closure *self.show_password_mut() = local_show_password; - - // Update the error message - self.set_error_message(local_error_message); - - // Display error message if the password was incorrect - if let Some(error_message) = self.error_message().cloned() { - ui.add_space(5.0); - let error_color = DashColors::error_color(ui.ctx().style().visuals.dark_mode); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new(format!("Error: {}", error_message)) - .color(error_color), - ); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.set_error_message(None); - } - }); - }); - } + // Error display is handled by the global MessageBanner } } diff --git a/src/ui/contracts_documents/add_contracts_screen.rs b/src/ui/contracts_documents/add_contracts_screen.rs index 449d184b6..03fb8ca93 100644 --- a/src/ui/contracts_documents/add_contracts_screen.rs +++ b/src/ui/contracts_documents/add_contracts_screen.rs @@ -3,6 +3,7 @@ use crate::backend_task::BackendTask; use crate::backend_task::contract::ContractTask; use crate::context::AppContext; use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::message_banner::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; use crate::ui::theme::DashColors; @@ -10,19 +11,17 @@ use crate::ui::{BackendTaskSuccessResult, MessageType, ScreenLike}; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::identifier::Identifier; use dash_sdk::dpp::platform_value::string_encoding::Encoding; -use dash_sdk::dpp::prelude::TimestampMillis; -use eframe::egui::{self, Color32, Context, Frame, Margin, RichText, Ui}; +use eframe::egui::{self, Color32, Context, RichText, Ui}; use std::sync::Arc; -use std::time::{SystemTime, UNIX_EPOCH}; const MAX_CONTRACTS: usize = 10; #[derive(PartialEq)] enum AddContractsStatus { NotStarted, - WaitingForResult(TimestampMillis), + WaitingForResult, Complete(Vec), - ErrorMessage(String), + Error, } pub struct AddContractsScreen { @@ -32,6 +31,7 @@ pub struct AddContractsScreen { maybe_found_contracts: Vec, alias_inputs: Option>, last_alias_result: Option<(usize, Result)>, + add_banner: Option, } impl AddContractsScreen { @@ -43,6 +43,7 @@ impl AddContractsScreen { maybe_found_contracts: vec![], alias_inputs: None, last_alias_result: None, + add_banner: None, } } @@ -79,18 +80,22 @@ impl AddContractsScreen { fn add_contracts_clicked(&mut self) -> AppAction { match self.parse_identifiers() { Ok(identifiers) => { - self.add_contracts_status = AddContractsStatus::WaitingForResult( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(), + self.add_banner.take_and_clear(); + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Adding contract...", + MessageType::Info, ); + handle.with_elapsed(); + self.add_banner = Some(handle); + self.add_contracts_status = AddContractsStatus::WaitingForResult; AppAction::BackendTask(BackendTask::ContractTask(Box::new( ContractTask::FetchContracts(identifiers), ))) } Err(e) => { - self.add_contracts_status = AddContractsStatus::ErrorMessage(e); + self.add_contracts_status = AddContractsStatus::Error; + MessageBanner::set_global(self.app_context.egui_ctx(), &e, MessageType::Error); AppAction::None } } @@ -269,10 +274,12 @@ impl AddContractsScreen { } impl ScreenLike for AddContractsScreen { - fn display_message(&mut self, message: &str, message_type: MessageType) { + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. match message_type { MessageType::Error | MessageType::Warning => { - self.add_contracts_status = AddContractsStatus::ErrorMessage(message.to_string()); + self.add_banner.take_and_clear(); + self.add_contracts_status = AddContractsStatus::Error; } MessageType::Success | MessageType::Info => {} } @@ -281,6 +288,7 @@ impl ScreenLike for AddContractsScreen { fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { match backend_task_success_result { BackendTaskSuccessResult::FetchedContracts(maybe_found_contracts) => { + self.add_banner.take_and_clear(); let maybe_contracts: Vec<_> = self .contract_ids_input .iter() @@ -324,29 +332,7 @@ impl ScreenLike for AddContractsScreen { ui.add_space(10.0); match &self.add_contracts_status { - AddContractsStatus::NotStarted | AddContractsStatus::ErrorMessage(_) => { - if let AddContractsStatus::ErrorMessage(msg) = &self.add_contracts_status { - let error_color = DashColors::ERROR; - let msg = msg.clone(); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new(format!("Error: {}", msg)).color(error_color), - ); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.add_contracts_status = AddContractsStatus::NotStarted; - } - }); - }); - ui.add_space(10.0); - } - + AddContractsStatus::NotStarted | AddContractsStatus::Error => { // Show input fields self.show_input_fields(ui); @@ -361,35 +347,8 @@ impl ScreenLike for AddContractsScreen { return self.add_contracts_clicked(); } } - AddContractsStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed_seconds = now - start_time; - - let display_time = if elapsed_seconds < 60 { - format!( - "{} second{}", - elapsed_seconds, - if elapsed_seconds == 1 { "" } else { "s" } - ) - } else { - let minutes = elapsed_seconds / 60; - let seconds = elapsed_seconds % 60; - format!( - "{} minute{} and {} second{}", - minutes, - if minutes == 1 { "" } else { "s" }, - seconds, - if seconds == 1 { "" } else { "s" } - ) - }; - - ui.label(format!( - "Fetching contracts... Time taken so far: {}", - display_time - )); + AddContractsStatus::WaitingForResult => { + // Elapsed time is shown in the global banner } AddContractsStatus::Complete(_) => { return self.show_success_screen(ui); diff --git a/src/ui/contracts_documents/contracts_documents_screen.rs b/src/ui/contracts_documents/contracts_documents_screen.rs index 9aa6d3738..3382368df 100644 --- a/src/ui/contracts_documents/contracts_documents_screen.rs +++ b/src/ui/contracts_documents/contracts_documents_screen.rs @@ -10,23 +10,21 @@ use crate::ui::components::contract_chooser_panel::{ ContractChooserState, add_contract_chooser_panel, }; use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::message_banner::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::components::top_panel::add_top_panel; use crate::ui::theme::{DashColors, Shadow, Shape}; use crate::ui::{BackendTaskSuccessResult, MessageType, RootScreenType, ScreenLike, ScreenType}; use crate::utils::parsers::{DocumentQueryTextInputParser, TextInputParser}; -use chrono::{DateTime, Utc}; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dash_sdk::dpp::data_contract::document_type::{DocumentType, Index}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; -use dash_sdk::dpp::prelude::TimestampMillis; use dash_sdk::platform::proto::get_documents_request::get_documents_request_v0::Start; use dash_sdk::platform::{Document, DocumentQuery, Identifier}; use egui::{CentralPanel, Color32, Context, Frame, Margin, ScrollArea, Stroke, Ui}; use std::collections::HashMap; use std::sync::Arc; -use std::time::{SystemTime, UNIX_EPOCH}; /// A list of Dash-specific fields that do not appear in the /// normal document_type properties. @@ -47,7 +45,6 @@ pub const DOCUMENT_PRIVATE_FIELDS: &[&str] = &[ pub struct DocumentQueryScreen { pub app_context: Arc, - error_message: Option<(String, MessageType, DateTime)>, contract_search_term: String, document_search_term: String, document_query: String, @@ -70,14 +67,15 @@ pub struct DocumentQueryScreen { previous_cursors: Vec, // Contract chooser state contract_chooser_state: ContractChooserState, + query_banner: Option, } #[derive(PartialEq, Eq, Clone)] pub enum DocumentQueryStatus { NotStarted, - WaitingForResult(TimestampMillis), + WaitingForResult, Complete, - ErrorMessage(String), + Error, } #[derive(PartialEq, Eq, Clone)] @@ -112,7 +110,6 @@ impl DocumentQueryScreen { Self { app_context: app_context.clone(), - error_message: None, contract_search_term: String::new(), document_search_term: String::new(), document_query: format!("SELECT * FROM {}", selected_document_type.name()), @@ -134,22 +131,7 @@ impl DocumentQueryScreen { has_next_page: false, previous_cursors: Vec::new(), contract_chooser_state: ContractChooserState::default(), - } - } - - fn dismiss_error(&mut self) { - self.error_message = None; - } - - fn check_error_expiration(&mut self) { - if let Some((_, _, timestamp)) = &self.error_message { - let now = Utc::now(); - let elapsed = now.signed_duration_since(*timestamp); - - // Automatically dismiss the error message after 10 seconds - if elapsed.num_seconds() > 10 { - self.dismiss_error(); - } + query_banner: None, } } @@ -208,12 +190,16 @@ impl DocumentQueryScreen { DocumentQueryTextInputParser::new(self.selected_data_contract.contract.clone()); match parser.parse_input(&self.document_query) { Ok(parsed_query) => { - // Set the status to waiting and capture the current time - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.document_query_status = DocumentQueryStatus::WaitingForResult(now); + // Set the status to waiting + self.query_banner.take_and_clear(); + let handle = MessageBanner::set_global( + ui.ctx(), + "Querying documents...", + MessageType::Info, + ); + handle.with_elapsed(); + self.query_banner = Some(handle); + self.document_query_status = DocumentQueryStatus::WaitingForResult; self.current_page = 1; // Reset to first page self.next_cursors = vec![]; // Reset cursor self.previous_cursors.clear(); // Clear previous cursors @@ -222,15 +208,13 @@ impl DocumentQueryScreen { ))); } Err(e) => { - self.document_query_status = DocumentQueryStatus::ErrorMessage(format!( - "Failed to parse query properly: {}", - e - )); - self.error_message = Some(( + self.query_banner.take_and_clear(); + self.document_query_status = DocumentQueryStatus::Error; + MessageBanner::set_global( + ui.ctx(), format!("Failed to parse query properly: {}", e), MessageType::Error, - Utc::now(), - )); + ); } } } @@ -241,7 +225,6 @@ impl DocumentQueryScreen { fn show_output(&mut self, ui: &mut Ui) -> AppAction { let mut action = AppAction::None; - let dark_mode = ui.ctx().style().visuals.dark_mode; ui.separator(); ui.add_space(10.0); @@ -343,19 +326,8 @@ impl DocumentQueryScreen { ui.set_width(ui.available_width()); match self.document_query_status { - DocumentQueryStatus::WaitingForResult(start_time) => { - let time_elapsed = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs() - - start_time; - ui.horizontal(|ui| { - ui.label(format!( - "Fetching documents... Time taken so far: {} seconds", - time_elapsed - )); - ui.add(egui::widgets::Spinner::default().color(DashColors::DASH_BLUE)); - }); + DocumentQueryStatus::WaitingForResult => { + // Elapsed time is shown in the global banner } DocumentQueryStatus::Complete => match self.document_display_mode { DocumentDisplayMode::Json => { @@ -366,10 +338,8 @@ impl DocumentQueryScreen { } }, - DocumentQueryStatus::ErrorMessage(ref message) => { - self.error_message = - Some((message.to_string(), MessageType::Error, Utc::now())); - ui.colored_label(DashColors::error_color(dark_mode), message); + DocumentQueryStatus::Error => { + // Error message is displayed globally via MessageBanner } _ => { // Nothing @@ -387,12 +357,15 @@ impl DocumentQueryScreen { if self.current_page > 1 && ui.button("Previous Page").clicked() { // Handle Previous Page if let Some(prev_cursor) = self.get_previous_cursor() { - self.document_query_status = DocumentQueryStatus::WaitingForResult( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(), + self.query_banner.take_and_clear(); + let handle = MessageBanner::set_global( + ui.ctx(), + "Querying documents...", + MessageType::Info, ); + handle.with_elapsed(); + self.query_banner = Some(handle); + self.document_query_status = DocumentQueryStatus::WaitingForResult; self.current_page -= 1; self.next_cursors.pop(); let parsed_query = self.build_document_query_with_cursor(&prev_cursor); @@ -400,12 +373,15 @@ impl DocumentQueryScreen { DocumentTask::FetchDocumentsPage(parsed_query), ))); } else { - self.document_query_status = DocumentQueryStatus::WaitingForResult( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(), + self.query_banner.take_and_clear(); + let handle = MessageBanner::set_global( + ui.ctx(), + "Querying documents...", + MessageType::Info, ); + handle.with_elapsed(); + self.query_banner = Some(handle); + self.document_query_status = DocumentQueryStatus::WaitingForResult; self.current_page = 1; let next_cursor = self.get_next_cursor().unwrap_or(Start::StartAfter(vec![])); // Doesn't matter what the value is @@ -421,12 +397,15 @@ impl DocumentQueryScreen { if self.has_next_page && ui.button("Next Page").clicked() { // Handle Next Page if let Some(next_cursor) = &self.get_next_cursor() { - self.document_query_status = DocumentQueryStatus::WaitingForResult( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(), + self.query_banner.take_and_clear(); + let handle = MessageBanner::set_global( + ui.ctx(), + "Querying documents...", + MessageType::Info, ); + handle.with_elapsed(); + self.query_banner = Some(handle); + self.document_query_status = DocumentQueryStatus::WaitingForResult; if self.current_page > 1 { self.previous_cursors.push( self.next_cursors @@ -539,7 +518,6 @@ impl ScreenLike for DocumentQueryScreen { fn refresh(&mut self) { // Reset the screen state - self.error_message = None; self.contract_search_term.clear(); self.document_search_term.clear(); self.document_query.clear(); @@ -563,16 +541,19 @@ impl ScreenLike for DocumentQueryScreen { } fn display_message(&mut self, message: &str, message_type: MessageType) { - // Only display the error message resulting from FetchDocuments backend task - if message.contains("Error fetching documents") { - self.document_query_status = DocumentQueryStatus::ErrorMessage(message.to_string()); - self.error_message = Some((message.to_string(), message_type, Utc::now())); + // Banner display is handled globally by AppState; this is only for side-effects. + if message.contains("Error fetching documents") + && matches!(message_type, MessageType::Error | MessageType::Warning) + { + self.query_banner.take_and_clear(); + self.document_query_status = DocumentQueryStatus::Error; } } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { match backend_task_success_result { BackendTaskSuccessResult::Documents(documents) => { + self.query_banner.take_and_clear(); self.matching_documents = documents .iter() .filter_map(|(_, doc)| doc.clone()) @@ -580,6 +561,7 @@ impl ScreenLike for DocumentQueryScreen { self.document_query_status = DocumentQueryStatus::Complete; } BackendTaskSuccessResult::PageDocuments(page_docs, next_cursor) => { + self.query_banner.take_and_clear(); self.matching_documents = page_docs .iter() .filter_map(|(_, doc)| doc.clone()) @@ -597,7 +579,6 @@ impl ScreenLike for DocumentQueryScreen { } fn ui(&mut self, ctx: &Context) -> AppAction { - self.check_error_expiration(); let load_contract_button = ( "Load Contracts", DesiredAppAction::AddScreenType(Box::new(ScreenType::AddContracts)), @@ -727,6 +708,7 @@ impl ScreenLike for DocumentQueryScreen { .corner_radius(egui::CornerRadius::same(Shape::RADIUS_LG)) .shadow(Shadow::elevated()) .show(ui, |ui| { + MessageBanner::show_global(ui); let mut inner_action = AppAction::None; // Use a vertical layout that allocates space properly diff --git a/src/ui/contracts_documents/document_action_screen.rs b/src/ui/contracts_documents/document_action_screen.rs index 4d0301d5f..0eb5ece0e 100644 --- a/src/ui/contracts_documents/document_action_screen.rs +++ b/src/ui/contracts_documents/document_action_screen.rs @@ -15,6 +15,7 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt, ResultBannerExt}; use crate::ui::helpers::{ TransactionType, add_contract_doc_type_chooser_with_filtering, add_key_chooser_with_doc_type, show_success_screen_with_info, @@ -51,7 +52,6 @@ use eframe::epaint::Color32; use egui::{Context, Frame, Margin, RichText, Ui}; use std::collections::{BTreeMap, HashMap}; use std::sync::{Arc, RwLock}; -use std::time::{SystemTime, UNIX_EPOCH}; #[derive(Debug, Clone, PartialEq)] pub enum DocumentActionType { @@ -79,9 +79,9 @@ impl DocumentActionType { #[derive(Clone, PartialEq)] pub enum BroadcastStatus { NotBroadcasted, - Fetching(u64), + Fetching, Fetched, - Broadcasting(u64), + Broadcasting, Broadcasted, } @@ -90,12 +90,13 @@ pub struct DocumentActionScreen { pub action_type: DocumentActionType, // Common fields - pub backend_message: Option, + no_documents_found: bool, pub selected_identity: Option, selected_identity_string: String, pub selected_key: Option, show_advanced_options: bool, pub wallet: Option>>, + wallet_open_attempted: bool, pub wallet_unlock_popup: WalletUnlockPopup, pub wallet_failure: Option, pub broadcast_status: BroadcastStatus, @@ -127,6 +128,9 @@ pub struct DocumentActionScreen { // Fee tracking pub completed_fee_result: Option, + + // Banner for in-progress operations + refresh_banner: Option, } impl DocumentActionScreen { @@ -159,12 +163,13 @@ impl DocumentActionScreen { Self { app_context, action_type, - backend_message: None, + no_documents_found: false, selected_identity, selected_identity_string, selected_key: None, show_advanced_options: false, wallet: None, + wallet_open_attempted: false, wallet_unlock_popup: WalletUnlockPopup::new(), wallet_failure: None, broadcast_status: BroadcastStatus::NotBroadcasted, @@ -180,16 +185,26 @@ impl DocumentActionScreen { recipient_id_input: String::new(), fetched_documents: IndexMap::new(), completed_fee_result: None, + refresh_banner: None, } } + fn set_fetching_banner(&mut self, ctx: &egui::Context, text: &str) { + self.refresh_banner.take_and_clear(); + let handle = MessageBanner::set_global(ctx, text, crate::ui::MessageType::Info); + handle.with_elapsed(); + self.refresh_banner = Some(handle); + } + fn reset_screen(&mut self) { - self.backend_message = None; + self.refresh_banner.take_and_clear(); + self.no_documents_found = false; self.selected_identity = None; self.selected_identity_string = String::new(); self.selected_key = None; self.show_advanced_options = false; self.wallet = None; + self.wallet_open_attempted = false; self.wallet_unlock_popup = WalletUnlockPopup::new(); self.wallet_failure = None; self.broadcast_status = BroadcastStatus::NotBroadcasted; @@ -209,6 +224,12 @@ impl DocumentActionScreen { ui.heading("1. Select a contract and document type:"); ui.add_space(10.0); + let prev_contract_id = self.selected_contract.as_ref().map(|c| c.contract.id()); + let prev_doc_type = self + .selected_document_type + .as_ref() + .map(|d| d.name().to_owned()); + add_contract_doc_type_chooser_with_filtering( ui, &mut self.contract_search, @@ -216,6 +237,19 @@ impl DocumentActionScreen { &mut self.selected_contract, &mut self.selected_document_type, ); + + let contract_changed = + prev_contract_id != self.selected_contract.as_ref().map(|c| c.contract.id()); + let doc_type_changed = prev_doc_type + != self + .selected_document_type + .as_ref() + .map(|d| d.name().to_owned()); + if contract_changed || doc_type_changed { + self.no_documents_found = false; + self.fetched_documents.clear(); + } + ui.add_space(10.0); } @@ -246,6 +280,8 @@ impl DocumentActionScreen { // Handle identity change - auto-select key and update wallet if response.changed() { + self.no_documents_found = false; + self.fetched_documents.clear(); if let Some(identity) = &self.selected_identity { // Auto-select a suitable key for document actions // Note: MASTER keys cannot be used for document operations, @@ -267,15 +303,14 @@ impl DocumentActionScreen { .cloned(); // Update wallet - self.wallet = get_selected_wallet( - identity, - Some(&self.app_context), - None, - &mut self.backend_message, - ); + self.wallet = get_selected_wallet(identity, Some(&self.app_context), None) + .or_show_error(self.app_context.egui_ctx()) + .unwrap_or(None); + self.wallet_open_attempted = false; } else { self.selected_key = None; self.wallet = None; + self.wallet_open_attempted = false; } } @@ -380,12 +415,8 @@ impl DocumentActionScreen { ui.label("This document type has an index on $ownerId, so you can fetch owned documents to view and select."); ui.add_space(10.0); if ui.button("Fetch Owned Documents").clicked() { - self.broadcast_status = BroadcastStatus::Fetching( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(), - ); + self.broadcast_status = BroadcastStatus::Fetching; + self.set_fetching_banner(ui.ctx(), "Fetching owned documents..."); action = AppAction::BackendTask(BackendTask::DocumentTask(Box::new( DocumentTask::FetchDocuments(query), ))); @@ -453,22 +484,15 @@ impl DocumentActionScreen { } } - if let Some(backend_message) = &self.backend_message - && backend_message.contains("No owned documents found") - { + if self.no_documents_found { ui.add_space(10.0); ui.label("No owned documents found."); } // Show fetching status - if let BroadcastStatus::Fetching(start) = &self.broadcast_status { - let elapsed = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs() - - start; + if self.broadcast_status == BroadcastStatus::Fetching { ui.add_space(10.0); - ui.label(format!("Fetching documents... {}s", elapsed)); + ui.spinner(); } ui.add_space(10.0); @@ -504,30 +528,25 @@ impl DocumentActionScreen { .expect("Failed to create document query"); query = query.with_document_id(&doc_id); - self.broadcast_status = BroadcastStatus::Fetching( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(), - ); + self.broadcast_status = BroadcastStatus::Fetching; + self.set_fetching_banner(ui.ctx(), "Fetching document price..."); action = AppAction::BackendTask(BackendTask::DocumentTask(Box::new( DocumentTask::FetchDocuments(query), ))); } } else { - self.backend_message = Some("Invalid Document ID format".to_string()); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Invalid Document ID format", + crate::ui::MessageType::Error, + ); } } // Show fetching status - if let BroadcastStatus::Fetching(start) = &self.broadcast_status { - let elapsed = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs() - - start; + if self.broadcast_status == BroadcastStatus::Fetching { ui.add_space(10.0); - ui.label(format!("Fetching document price... {}s", elapsed)); + ui.spinner(); } if let Some(price) = self.fetched_price { @@ -569,31 +588,26 @@ impl DocumentActionScreen { .expect("Failed to create document query"); query = query.with_document_id(&doc_id); - self.broadcast_status = BroadcastStatus::Fetching( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(), - ); + self.broadcast_status = BroadcastStatus::Fetching; + self.set_fetching_banner(ui.ctx(), "Fetching document..."); action = AppAction::BackendTask(BackendTask::DocumentTask(Box::new( DocumentTask::FetchDocuments(query), ))); } } else { - self.backend_message = Some("Invalid Document ID format".to_string()); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Invalid Document ID format", + crate::ui::MessageType::Error, + ); } } }); // Show fetching status - if let BroadcastStatus::Fetching(start) = &self.broadcast_status { - let elapsed = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs() - - start; + if self.broadcast_status == BroadcastStatus::Fetching { ui.add_space(10.0); - ui.label(format!("Fetching document... {}s", elapsed)); + ui.spinner(); } if let Some(_original_doc) = &self.original_doc { @@ -919,38 +933,19 @@ impl DocumentActionScreen { .min_size(egui::vec2(100.0, 30.0)); if ui.add(button).clicked() && self.can_broadcast() { - self.backend_message = None; let task = self.create_document_action(); if task != BackendTask::None { - self.broadcast_status = BroadcastStatus::Broadcasting( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(), - ); + self.broadcast_status = BroadcastStatus::Broadcasting; + self.set_fetching_banner(ui.ctx(), "Broadcasting..."); action = AppAction::BackendTask(task); } } // Status display match &self.broadcast_status { - BroadcastStatus::Broadcasting(start_time) => { + BroadcastStatus::Broadcasting | BroadcastStatus::Fetching => { ui.add_space(10.0); - let elapsed = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs() - - start_time; - ui.label(format!("Broadcasting... {}s", elapsed)); - } - BroadcastStatus::Fetching(start_time) => { - ui.add_space(10.0); - let elapsed = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs() - - start_time; - ui.label(format!("Fetching... {}s", elapsed)); + ui.spinner(); } _ => {} } @@ -999,7 +994,11 @@ impl DocumentActionScreen { ))) } Err(e) => { - self.backend_message = Some(format!("Failed to build document: {}", e)); + MessageBanner::set_global( + self.app_context.egui_ctx(), + format!("Failed to build document: {}", e), + crate::ui::MessageType::Error, + ); BackendTask::None } } @@ -1094,7 +1093,11 @@ impl DocumentActionScreen { ))) } Err(e) => { - self.backend_message = Some(format!("Failed to build updated document: {}", e)); + MessageBanner::set_global( + self.app_context.egui_ctx(), + format!("Failed to build updated document: {}", e), + crate::ui::MessageType::Error, + ); BackendTask::None } } @@ -1629,12 +1632,20 @@ impl ScreenLike for DocumentActionScreen { // Backend messages are handled via display_message } - fn display_message(&mut self, message: &str, _message_type: crate::ui::MessageType) { - self.backend_message = Some(message.to_string()); + fn display_message(&mut self, _message: &str, message_type: crate::ui::MessageType) { + if matches!( + message_type, + crate::ui::MessageType::Error | crate::ui::MessageType::Warning + ) { + self.refresh_banner.take_and_clear(); + } + // Banner display is handled globally by AppState; this is only for side-effects. self.broadcast_status = BroadcastStatus::NotBroadcasted; } fn display_task_result(&mut self, result: crate::ui::BackendTaskSuccessResult) { + // Clear the progress banner on any completed task + self.refresh_banner.take_and_clear(); match result { BackendTaskSuccessResult::BroadcastedDocument(_) => { self.broadcast_status = BroadcastStatus::Broadcasted; @@ -1703,28 +1714,34 @@ impl ScreenLike for DocumentActionScreen { self.fetched_price = Some(price); } Ok(None) => { - self.backend_message = - Some("Document has no price set".to_string()); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Document has no price set", + crate::ui::MessageType::Error, + ); self.fetched_price = None; } Err(_) => { - self.backend_message = - Some("Failed to get document price".to_string()); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Failed to get document price", + crate::ui::MessageType::Error, + ); self.fetched_price = None; } } } else { - self.backend_message = Some("No document found".to_string()); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "No document found", + crate::ui::MessageType::Error, + ); self.fetched_price = None; } } DocumentActionType::Delete => { // For delete, store the fetched documents - if documents.is_empty() { - self.backend_message = Some("No owned documents found".to_string()); - } else { - self.backend_message = None; - } + self.no_documents_found = documents.is_empty(); self.fetched_documents = documents; } _ => {} @@ -1765,16 +1782,31 @@ impl DocumentActionScreen { // Wallet unlock if let Some(selected_identity) = &self.selected_identity { - self.wallet = get_selected_wallet( - selected_identity, - Some(&self.app_context), - None, - &mut self.backend_message, - ); + let new_wallet = + get_selected_wallet(selected_identity, Some(&self.app_context), None) + .or_show_error(self.app_context.egui_ctx()) + .unwrap_or(None); + let wallet_changed = match (&self.wallet, &new_wallet) { + (Some(a), Some(b)) => !Arc::ptr_eq(a, b), + (None, None) => false, + _ => true, + }; + if wallet_changed { + self.wallet_open_attempted = false; + } + self.wallet = new_wallet; } if let Some(wallet) = &self.wallet { - if let Err(e) = try_open_wallet_no_password(wallet) { - self.backend_message = Some(e); + if !self.wallet_open_attempted { + if let Err(e) = try_open_wallet_no_password(wallet) { + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Unable to open wallet. Please unlock it and try again.", + crate::ui::MessageType::Error, + ) + .with_details(e); + } + self.wallet_open_attempted = true; } if wallet_needs_unlock(wallet) { ui.add_space(10.0); @@ -1796,26 +1828,6 @@ impl DocumentActionScreen { _ => self.render_action_specific_inputs(ui), }; - if let Some(ref msg) = self.backend_message { - ui.add_space(10.0); - let error_color = DashColors::error_color(ui.visuals().dark_mode); - let msg = msg.clone(); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new(&msg).color(error_color)); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.backend_message = None; - } - }); - }); - } - action }) .inner diff --git a/src/ui/contracts_documents/group_actions_screen.rs b/src/ui/contracts_documents/group_actions_screen.rs index 3ae003e61..ed877deb6 100644 --- a/src/ui/contracts_documents/group_actions_screen.rs +++ b/src/ui/contracts_documents/group_actions_screen.rs @@ -17,6 +17,7 @@ use crate::model::qualified_contract::QualifiedContract; use crate::model::qualified_identity::QualifiedIdentity; use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::message_banner::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; use crate::ui::helpers::add_contract_chooser_pre_filtered; @@ -44,24 +45,22 @@ use dash_sdk::dpp::group::action_event::GroupActionEvent; use dash_sdk::dpp::group::group_action::{GroupAction, GroupActionAccessors}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::platform_value::string_encoding::Encoding; -use dash_sdk::dpp::prelude::TimestampMillis; use dash_sdk::dpp::tokens::emergency_action::TokenEmergencyAction; use dash_sdk::dpp::tokens::token_event::TokenEvent; use dash_sdk::platform::Identifier; use dash_sdk::query_types::IndexMap; -use eframe::egui::{self, Color32, Context, Frame, Margin, RichText}; +use eframe::egui::{self, Color32, Context, RichText}; use egui::{ScrollArea, TextStyle}; use egui_extras::{Column, TableBuilder}; use std::collections::BTreeMap; use std::sync::Arc; -use std::time::{SystemTime, UNIX_EPOCH}; // Status of the fetch group actions task enum FetchGroupActionsStatus { NotStarted, - WaitingForResult(TimestampMillis), + WaitingForResult, Complete(IndexMap), - ErrorMessage(String), + Error, } /// The screen @@ -84,6 +83,7 @@ pub struct GroupActionsScreen { // Backend task status fetch_group_actions_status: FetchGroupActionsStatus, + fetch_banner: Option, // App Context pub app_context: Arc, @@ -149,6 +149,7 @@ impl GroupActionsScreen { // Backend task status fetch_group_actions_status: FetchGroupActionsStatus::NotStarted, + fetch_banner: None, // App Context app_context: app_context.clone(), @@ -259,9 +260,12 @@ impl GroupActionsScreen { Some(identity_token_balance) => identity_token_balance, None => { self.fetch_group_actions_status = - FetchGroupActionsStatus::ErrorMessage( - "No identity token balance found".to_string(), - ); + FetchGroupActionsStatus::Error; + MessageBanner::set_global( + ui.ctx(), + "No identity token balance found", + MessageType::Error, + ); return; } }; @@ -269,9 +273,12 @@ impl GroupActionsScreen { Ok(identity_token_info) => identity_token_info, Err(e) => { self.fetch_group_actions_status = - FetchGroupActionsStatus::ErrorMessage( - format!("Failed to get identity token info: {}", e), - ); + FetchGroupActionsStatus::Error; + MessageBanner::set_global( + ui.ctx(), + format!("Failed to get identity token info: {}", e), + MessageType::Error, + ); return; } }; @@ -357,7 +364,6 @@ impl GroupActionsScreen { TokenEvent::Mint(amount, _identifier, note_opt) => { let mut mint_screen = MintTokensScreen::new(identity_token_info, &self.app_context); mint_screen.group_action_id = Some(action_id); - // Convert amount to Amount struct using the token configuration mint_screen.amount = Some(Amount::from_token( &mint_screen.identity_token_info, *amount, @@ -451,11 +457,12 @@ impl ScreenLike for GroupActionsScreen { self.fetch_group_actions_status = FetchGroupActionsStatus::NotStarted; } - fn display_message(&mut self, message: &str, message_type: MessageType) { + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. match message_type { MessageType::Error | MessageType::Warning => { - self.fetch_group_actions_status = - FetchGroupActionsStatus::ErrorMessage(message.to_string()); + self.fetch_banner.take_and_clear(); + self.fetch_group_actions_status = FetchGroupActionsStatus::Error; } MessageType::Success | MessageType::Info => {} } @@ -465,6 +472,7 @@ impl ScreenLike for GroupActionsScreen { if let BackendTaskSuccessResult::ActiveGroupActions(actions_map) = backend_task_success_result { + self.fetch_banner.take_and_clear(); self.fetch_group_actions_status = FetchGroupActionsStatus::Complete(actions_map.clone()); } @@ -556,62 +564,26 @@ impl ScreenLike for GroupActionsScreen { .corner_radius(3.0); if ui.add(button).clicked() { - self.fetch_group_actions_status = FetchGroupActionsStatus::WaitingForResult( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(), + self.fetch_banner.take_and_clear(); + let handle = MessageBanner::set_global( + ui.ctx(), + "Fetching group actions...", + MessageType::Info, ); + handle.with_elapsed(); + self.fetch_banner = Some(handle); + self.fetch_group_actions_status = FetchGroupActionsStatus::WaitingForResult; fetch_clicked = true; } } match &self.fetch_group_actions_status { - FetchGroupActionsStatus::ErrorMessage(msg) => { - ui.add_space(10.0); - let error_color = DashColors::ERROR; - let msg = msg.clone(); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new(format!("Error: {}", msg)).color(error_color), - ); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.fetch_group_actions_status = - FetchGroupActionsStatus::NotStarted; - } - }); - }); + FetchGroupActionsStatus::Error => { + // Error message is displayed globally via MessageBanner } - FetchGroupActionsStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed = now - start_time; - let status = if elapsed < 60 { - format!("{} second{}", elapsed, if elapsed == 1 { "" } else { "s" }) - } else { - format!( - "{} minute{} and {} second{}", - elapsed / 60, - if elapsed / 60 == 1 { "" } else { "s" }, - elapsed % 60, - if elapsed % 60 == 1 { "" } else { "s" } - ) - }; - ui.add_space(10.0); - ui.label(format!( - "Fetching group actions… Time taken so far: {}", - status - )); + FetchGroupActionsStatus::WaitingForResult => { + // Elapsed time is shown in the global banner } _ => {} diff --git a/src/ui/contracts_documents/register_contract_screen.rs b/src/ui/contracts_documents/register_contract_screen.rs index 0ad014a53..2fc5fc8ca 100644 --- a/src/ui/contracts_documents/register_contract_screen.rs +++ b/src/ui/contracts_documents/register_contract_screen.rs @@ -13,6 +13,7 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt, ResultBannerExt}; use crate::ui::helpers::{TransactionType, add_key_chooser}; use crate::ui::identities::get_selected_wallet; use crate::ui::theme::DashColors; @@ -26,15 +27,14 @@ use dash_sdk::platform::{DataContract, IdentityPublicKey}; use eframe::egui::{self, Color32, Context, Frame, Margin, TextEdit}; use egui::{RichText, ScrollArea, Ui}; use std::sync::{Arc, RwLock}; -use std::time::{SystemTime, UNIX_EPOCH}; #[derive(PartialEq)] enum BroadcastStatus { Idle, ParsingError(String), ValidContract(Box), - Broadcasting(u64), - ProofError(u64), + Broadcasting, + ProofError, BroadcastError(String), Done, } @@ -52,9 +52,10 @@ pub struct RegisterDataContractScreen { show_advanced_options: bool, pub selected_wallet: Option>>, + wallet_open_attempted: bool, wallet_unlock_popup: WalletUnlockPopup, - error_message: Option, completed_fee_result: Option, + refresh_banner: Option, } impl RegisterDataContractScreen { @@ -64,9 +65,8 @@ impl RegisterDataContractScreen { let selected_qualified_identity = qualified_identities.first().cloned(); - let mut error_message: Option = None; let selected_wallet = if let Some(ref identity) = selected_qualified_identity { - get_selected_wallet(identity, Some(app_context), None, &mut error_message) + get_selected_wallet(identity, Some(app_context), None).unwrap_or(None) } else { None }; @@ -107,9 +107,10 @@ impl RegisterDataContractScreen { show_advanced_options: false, selected_wallet, + wallet_open_attempted: false, wallet_unlock_popup: WalletUnlockPopup::new(), - error_message: None, completed_fee_result: None, + refresh_banner: None, } } @@ -264,26 +265,11 @@ impl RegisterDataContractScreen { ))); } } - BroadcastStatus::Broadcasting(start_time) => { - // Show how long we've been broadcasting - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed = now - start_time; - ui.label(format!( - "Broadcasting contract... {} seconds elapsed.", - elapsed - )); + BroadcastStatus::Broadcasting => { + ui.spinner(); } - BroadcastStatus::ProofError(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed = now - start_time; - ui.label("Broadcasted but received proof error. ⚠"); - ui.label(format!("Fetching contract from Platform and inserting into DET... {elapsed} seconds elapsed.")); + BroadcastStatus::ProofError => { + ui.spinner(); } BroadcastStatus::Done => { ui.colored_label( @@ -296,12 +282,12 @@ impl RegisterDataContractScreen { if let AppAction::BackendTask(BackendTask::ContractTask(contract_task)) = &app_action && let ContractTask::RegisterDataContract(_, _, _, _) = **contract_task { - self.broadcast_status = BroadcastStatus::Broadcasting( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(), - ); + self.broadcast_status = BroadcastStatus::Broadcasting; + self.refresh_banner.take_and_clear(); + let handle = + MessageBanner::set_global(ui.ctx(), "Broadcasting contract...", MessageType::Info); + handle.with_elapsed(); + self.refresh_banner = Some(handle); } app_action @@ -341,9 +327,11 @@ impl RegisterDataContractScreen { impl ScreenLike for RegisterDataContractScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.refresh_banner.take_and_clear(); + } if message_type == MessageType::Error { if message.contains("proof error logged, contract inserted into the database") { - self.error_message = Some(message.to_string()); self.broadcast_status = BroadcastStatus::Done; } else { self.broadcast_status = BroadcastStatus::BroadcastError(message.to_string()); @@ -354,24 +342,15 @@ impl ScreenLike for RegisterDataContractScreen { fn display_task_result(&mut self, result: BackendTaskSuccessResult) { match result { BackendTaskSuccessResult::FetchedNonce => { - self.broadcast_status = BroadcastStatus::Broadcasting( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(), - ); + self.broadcast_status = BroadcastStatus::Broadcasting; } BackendTaskSuccessResult::RegisteredContract(fee_result) => { + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.broadcast_status = BroadcastStatus::Done; } BackendTaskSuccessResult::ProofErrorLogged => { - self.broadcast_status = BroadcastStatus::ProofError( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(), - ); + self.broadcast_status = BroadcastStatus::ProofError; } _ => {} } @@ -480,14 +459,17 @@ impl ScreenLike for RegisterDataContractScreen { identity, Some(&self.app_context), None, - &mut self.error_message, - ); + ) + .or_show_error(self.app_context.egui_ctx()) + .unwrap_or(None); + self.wallet_open_attempted = false; // Re-parse contract with new owner ID self.parse_contract(); } else { self.selected_key = None; self.selected_wallet = None; + self.wallet_open_attempted = false; } } @@ -523,8 +505,10 @@ impl ScreenLike for RegisterDataContractScreen { // Render wallet unlock if needed if let Some(wallet) = &self.selected_wallet { - if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + if !self.wallet_open_attempted { + let _ = try_open_wallet_no_password(wallet) + .or_show_error(ui.ctx()); + self.wallet_open_attempted = true; } if wallet_needs_unlock(wallet) { ui.add_space(10.0); diff --git a/src/ui/contracts_documents/update_contract_screen.rs b/src/ui/contracts_documents/update_contract_screen.rs index f88293156..933c3066f 100644 --- a/src/ui/contracts_documents/update_contract_screen.rs +++ b/src/ui/contracts_documents/update_contract_screen.rs @@ -14,6 +14,7 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt, ResultBannerExt}; use crate::ui::helpers::{TransactionType, add_key_chooser}; use crate::ui::identities::get_selected_wallet; use crate::ui::theme::DashColors; @@ -29,16 +30,15 @@ use eframe::egui::{self, Color32, Context, Frame, Margin, TextEdit}; use egui::{RichText, ScrollArea, Ui}; use std::collections::HashSet; use std::sync::{Arc, RwLock}; -use std::time::{SystemTime, UNIX_EPOCH}; #[derive(PartialEq)] enum BroadcastStatus { Idle, ParsingError(String), ValidContract(Box), - FetchingNonce(u64), - Broadcasting(u64), - ProofError(u64), + FetchingNonce, + Broadcasting, + ProofError, BroadcastError(String), Done, } @@ -57,9 +57,10 @@ pub struct UpdateDataContractScreen { show_advanced_options: bool, pub selected_wallet: Option>>, + wallet_open_attempted: bool, wallet_unlock_popup: WalletUnlockPopup, - error_message: Option, completed_fee_result: Option, + refresh_banner: Option, } impl UpdateDataContractScreen { @@ -68,9 +69,8 @@ impl UpdateDataContractScreen { app_context.load_local_user_identities().unwrap_or_default(); let selected_qualified_identity = qualified_identities.first().cloned(); - let mut error_message: Option = None; let selected_wallet = if let Some(ref identity) = selected_qualified_identity { - get_selected_wallet(identity, Some(app_context), None, &mut error_message) + get_selected_wallet(identity, Some(app_context), None).unwrap_or(None) } else { None }; @@ -117,9 +117,10 @@ impl UpdateDataContractScreen { show_advanced_options: false, selected_wallet, + wallet_open_attempted: false, wallet_unlock_popup: WalletUnlockPopup::new(), - error_message: None, completed_fee_result: None, + refresh_banner: None, } } @@ -273,42 +274,14 @@ impl UpdateDataContractScreen { ))); } } - BroadcastStatus::FetchingNonce(start_time) => { - // Show how long we've been fetching nonce - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed = now - start_time; - ui.label(format!( - "Fetching identity contract nonce... {} seconds elapsed.", - elapsed - )); + BroadcastStatus::FetchingNonce => { + ui.spinner(); } - BroadcastStatus::Broadcasting(start_time) => { - // Show how long we've been broadcasting - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed = now - start_time; - ui.label("Fetched nonce successfully. ✅ "); - ui.label(format!( - "Broadcasting contract... {} seconds elapsed.", - elapsed - )); + BroadcastStatus::Broadcasting => { + ui.spinner(); } - BroadcastStatus::ProofError(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed = now - start_time; - ui.label("Fetched nonce successfully. ✅ "); - ui.label("Broadcasted but received proof error. ⚠"); - ui.label(format!( - "Fetching contract from Platform... {elapsed} seconds elapsed." - )); + BroadcastStatus::ProofError => { + ui.spinner(); } BroadcastStatus::Done => { ui.colored_label(Color32::DARK_GREEN, "Data Contract updated successfully!"); @@ -318,12 +291,15 @@ impl UpdateDataContractScreen { if let AppAction::BackendTask(BackendTask::ContractTask(contract_task)) = &app_action && let ContractTask::UpdateDataContract(_, _, _) = **contract_task { - self.broadcast_status = BroadcastStatus::FetchingNonce( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(), + self.broadcast_status = BroadcastStatus::FetchingNonce; + self.refresh_banner.take_and_clear(); + let handle = MessageBanner::set_global( + ui.ctx(), + "Fetching identity contract nonce...", + MessageType::Info, ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); } app_action @@ -362,9 +338,11 @@ impl UpdateDataContractScreen { impl ScreenLike for UpdateDataContractScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.refresh_banner.take_and_clear(); + } if message_type == MessageType::Error { if message.contains("proof error logged, contract inserted into the database") { - self.error_message = Some(message.to_string()); self.broadcast_status = BroadcastStatus::Done; } else { self.broadcast_status = BroadcastStatus::BroadcastError(message.to_string()); @@ -375,24 +353,23 @@ impl ScreenLike for UpdateDataContractScreen { fn display_task_result(&mut self, result: BackendTaskSuccessResult) { match result { BackendTaskSuccessResult::FetchedNonce => { - self.broadcast_status = BroadcastStatus::Broadcasting( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(), - ); + self.broadcast_status = BroadcastStatus::Broadcasting; + // Update banner text for the broadcasting phase + if let Some(handle) = &self.refresh_banner { + handle.set_message("Broadcasting contract..."); + } } BackendTaskSuccessResult::UpdatedContract(fee_result) => { + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.broadcast_status = BroadcastStatus::Done; } BackendTaskSuccessResult::ProofErrorLogged => { - self.broadcast_status = BroadcastStatus::ProofError( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(), - ); + self.broadcast_status = BroadcastStatus::ProofError; + // Update banner text for the proof error recovery phase + if let Some(handle) = &self.refresh_banner { + handle.set_message("Fetching contract from Platform..."); + } } _ => {} } @@ -494,18 +471,18 @@ impl ScreenLike for UpdateDataContractScreen { .cloned(); // Update wallet - self.selected_wallet = get_selected_wallet( - identity, - Some(&self.app_context), - None, - &mut self.error_message, - ); + self.selected_wallet = + get_selected_wallet(identity, Some(&self.app_context), None) + .or_show_error(self.app_context.egui_ctx()) + .unwrap_or(None); + self.wallet_open_attempted = false; // Re-parse contract with new owner ID self.parse_contract(); } else { self.selected_key = None; self.selected_wallet = None; + self.wallet_open_attempted = false; } } @@ -541,8 +518,9 @@ impl ScreenLike for UpdateDataContractScreen { // Render the wallet unlock if needed if let Some(wallet) = &self.selected_wallet { - if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + if !self.wallet_open_attempted { + let _ = try_open_wallet_no_password(wallet).or_show_error(ui.ctx()); + self.wallet_open_attempted = true; } if wallet_needs_unlock(wallet) { ui.add_space(10.0); diff --git a/src/ui/dashpay/add_contact_screen.rs b/src/ui/dashpay/add_contact_screen.rs index eb16e18f1..3c82e26ec 100644 --- a/src/ui/dashpay/add_contact_screen.rs +++ b/src/ui/dashpay/add_contact_screen.rs @@ -5,6 +5,7 @@ use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; +use crate::ui::components::ResultBannerExt; use crate::ui::components::dashpay_subscreen_chooser_panel::add_dashpay_subscreen_chooser_panel; use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::info_popup::InfoPopup; @@ -45,12 +46,12 @@ pub struct AddContactScreen { selected_key: Option, username_or_id: String, account_label: String, - message: Option<(String, MessageType)>, status: ContactRequestStatus, show_info_popup: bool, show_advanced_options: bool, selected_wallet: Option>>, wallet_unlock_popup: WalletUnlockPopup, + wallet_open_attempted: bool, } impl AddContactScreen { @@ -62,12 +63,12 @@ impl AddContactScreen { selected_key: None, username_or_id: String::new(), account_label: String::new(), - message: None, status: ContactRequestStatus::NotStarted, show_info_popup: false, show_advanced_options: false, selected_wallet: None, wallet_unlock_popup: WalletUnlockPopup::new(), + wallet_open_attempted: false, } } @@ -79,12 +80,12 @@ impl AddContactScreen { selected_key: None, username_or_id: identity_id, account_label: String::new(), - message: None, status: ContactRequestStatus::NotStarted, show_info_popup: false, show_advanced_options: false, selected_wallet: None, wallet_unlock_popup: WalletUnlockPopup::new(), + wallet_open_attempted: false, } } @@ -97,8 +98,7 @@ impl AddContactScreen { let error = DashPayError::MissingField { field: "username or identity ID".to_string(), }; - self.status = ContactRequestStatus::Error(error.clone()); - self.display_message(&error.user_message(), MessageType::Error); + self.status = ContactRequestStatus::Error(error); return AppAction::None; } @@ -107,8 +107,7 @@ impl AddContactScreen { let error = DashPayError::InvalidUsername { username: self.username_or_id.clone(), }; - self.status = ContactRequestStatus::Error(error.clone()); - self.display_message(&error.user_message(), MessageType::Error); + self.status = ContactRequestStatus::Error(error); return AppAction::None; } @@ -118,8 +117,7 @@ impl AddContactScreen { length: self.account_label.len(), max: 100, }; - self.status = ContactRequestStatus::Error(error.clone()); - self.display_message(&error.user_message(), MessageType::Error); + self.status = ContactRequestStatus::Error(error); return AppAction::None; } @@ -148,8 +146,7 @@ impl AddContactScreen { field: "signing key".to_string(), } }; - self.status = ContactRequestStatus::Error(error.clone()); - self.display_message(&error.user_message(), MessageType::Error); + self.status = ContactRequestStatus::Error(error); AppAction::None } } @@ -190,7 +187,6 @@ impl ScreenLike for AddContactScreen { if !matches!(self.status, ContactRequestStatus::Success(_)) { self.status = ContactRequestStatus::NotStarted; } - self.message = None; } fn ui(&mut self, ctx: &Context) -> AppAction { @@ -235,20 +231,6 @@ impl ScreenLike for AddContactScreen { }); ui.separator(); - // Show message if any (but not if we have an error status, to avoid duplication) - if !matches!(self.status, ContactRequestStatus::Error(_)) - && let Some((message, message_type)) = &self.message - { - let color = match message_type { - MessageType::Success => egui::Color32::DARK_GREEN, - MessageType::Error => egui::Color32::DARK_RED, - MessageType::Warning => DashColors::WARNING, - MessageType::Info => egui::Color32::LIGHT_BLUE, - }; - ui.colored_label(color, message); - ui.separator(); - } - // Identity and Key selector let identities = self .app_context @@ -307,17 +289,16 @@ impl ScreenLike for AddContactScreen { // Update wallet if not already set if self.selected_wallet.is_none() { - let mut error_message = None; - self.selected_wallet = get_selected_wallet( - identity, - Some(&self.app_context), - None, - &mut error_message, - ); + self.selected_wallet = + get_selected_wallet(identity, Some(&self.app_context), None) + .or_show_error(self.app_context.egui_ctx()) + .unwrap_or(None); + self.wallet_open_attempted = false; } } else { self.selected_key = None; self.selected_wallet = None; + self.wallet_open_attempted = false; } } @@ -519,8 +500,15 @@ impl ScreenLike for AddContactScreen { // Check wallet lock status before showing send button let wallet_locked = if let Some(wallet) = &self.selected_wallet { - if let Err(e) = try_open_wallet_no_password(wallet) { - self.message = Some((e, MessageType::Error)); + if !self.wallet_open_attempted { + if let Err(e) = try_open_wallet_no_password(wallet) { + crate::ui::components::MessageBanner::set_global( + ui.ctx(), + &e, + MessageType::Error, + ); + } + self.wallet_open_attempted = true; } wallet_needs_unlock(wallet) } else { @@ -575,9 +563,8 @@ impl ScreenLike for AddContactScreen { { ui.add_space(10.0); if ui.button("Retry").clicked() { - // Clear both status and message before retrying + // Clear status before retrying self.status = ContactRequestStatus::NotStarted; - self.message = None; inner_action |= self.send_contact_request(); } } @@ -618,8 +605,8 @@ impl ScreenLike for AddContactScreen { } fn display_message(&mut self, message: &str, message_type: MessageType) { - self.message = Some((message.to_string(), message_type)); - if message_type == MessageType::Error { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { let error = DashPayError::Internal { message: message.to_string(), }; @@ -641,7 +628,10 @@ impl ScreenLike for AddContactScreen { self.selected_key = None; } BackendTaskSuccessResult::Message(message) => { - // Handle error messages only - success is handled by DashPayContactRequestSent + // TODO(RUST-002): Replace string-based error matching with structured + // error types through the task result system. This is fragile — if + // upstream error wording changes, classification silently breaks. + // See: https://github.com/dashpay/dash-evo-tool/issues/660 if message.contains("Error") || message.contains("Failed") || message.contains("does not have") @@ -673,9 +663,7 @@ impl ScreenLike for AddContactScreen { } }; - self.status = ContactRequestStatus::Error(error.clone()); - // Don't set message field to avoid duplicate error display - self.message = None; + self.status = ContactRequestStatus::Error(error); } // Ignore other messages - they're not for this screen } diff --git a/src/ui/dashpay/contact_details.rs b/src/ui/dashpay/contact_details.rs index b904101a4..68baa3d90 100644 --- a/src/ui/dashpay/contact_details.rs +++ b/src/ui/dashpay/contact_details.rs @@ -3,6 +3,7 @@ use crate::backend_task::dashpay::DashPayTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; use crate::model::qualified_identity::QualifiedIdentity; +use crate::ui::components::MessageBanner; use crate::ui::components::dashpay_subscreen_chooser_panel::add_dashpay_subscreen_chooser_panel; use crate::ui::components::info_popup::InfoPopup; use crate::ui::components::left_panel::add_left_panel; @@ -55,7 +56,6 @@ pub struct ContactDetailsScreen { edit_nickname: String, edit_note: String, edit_hidden: bool, - message: Option<(String, MessageType)>, loading: bool, show_info_popup: bool, needs_backend_fetch: bool, @@ -77,7 +77,6 @@ impl ContactDetailsScreen { edit_nickname: String::new(), edit_note: String::new(), edit_hidden: false, - message: None, loading: false, show_info_popup: false, needs_backend_fetch: true, @@ -257,18 +256,6 @@ impl ContactDetailsScreen { ui.separator(); - // Show message if any - if let Some((message, message_type)) = &self.message { - let color = match message_type { - MessageType::Success => DashColors::SUCCESS, - MessageType::Error => DashColors::ERROR, - MessageType::Warning => DashColors::WARNING, - MessageType::Info => DashColors::INFO, - }; - ui.colored_label(color, message); - ui.separator(); - } - // Loading indicator if self.loading { ui.horizontal(|ui| { @@ -520,21 +507,15 @@ impl ContactDetailsScreen { action } - - pub fn display_message(&mut self, message: &str, message_type: MessageType) { - self.message = Some((message.to_string(), message_type)); - } } impl ScreenLike for ContactDetailsScreen { fn refresh(&mut self) { self.load_from_database(); - self.message = None; } fn refresh_on_arrival(&mut self) { self.load_from_database(); - self.message = None; // Flag that we need a backend fetch; it will be dispatched from render() self.needs_backend_fetch = true; } @@ -587,8 +568,9 @@ impl ScreenLike for ContactDetailsScreen { action } - fn display_message(&mut self, message: &str, message_type: MessageType) { - self.display_message(message, message_type); + fn display_message(&mut self, _message: &str, _message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + self.loading = false; } fn display_task_result(&mut self, result: BackendTaskSuccessResult) { @@ -656,7 +638,11 @@ impl ScreenLike for ContactDetailsScreen { } BackendTaskSuccessResult::DashPayContactInfoUpdated(contact_id) => { if contact_id == self.contact_id { - self.display_message("Contact info saved to Platform", MessageType::Success); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Contact info saved to Platform", + MessageType::Success, + ); } } BackendTaskSuccessResult::DashPayContactsWithInfo(contacts_data) => { diff --git a/src/ui/dashpay/contact_info_editor.rs b/src/ui/dashpay/contact_info_editor.rs index d2b0d74d5..34d5a2a24 100644 --- a/src/ui/dashpay/contact_info_editor.rs +++ b/src/ui/dashpay/contact_info_editor.rs @@ -12,6 +12,7 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{MessageBanner, ResultBannerExt}; use crate::ui::dashpay::DashPaySubscreen; use crate::ui::identities::get_selected_wallet; use crate::ui::theme::DashColors; @@ -37,11 +38,11 @@ pub struct ContactInfoEditorScreen { is_hidden: bool, accepted_accounts: Vec, account_input: String, - message: Option<(String, MessageType)>, saving: bool, show_info_popup: bool, selected_wallet: Option>>, wallet_unlock_popup: WalletUnlockPopup, + wallet_open_attempted: bool, } impl ContactInfoEditorScreen { @@ -51,9 +52,9 @@ impl ContactInfoEditorScreen { contact_id: Identifier, ) -> Self { // Get wallet for the identity - let mut error_message = None; - let selected_wallet = - get_selected_wallet(&identity, Some(&app_context), None, &mut error_message); + let selected_wallet = get_selected_wallet(&identity, Some(&app_context), None) + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None); Self { app_context, @@ -65,11 +66,11 @@ impl ContactInfoEditorScreen { is_hidden: false, accepted_accounts: Vec::new(), account_input: String::new(), - message: None, saving: false, show_info_popup: false, selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), + wallet_open_attempted: false, } } @@ -135,18 +136,6 @@ impl ContactInfoEditorScreen { ui.separator(); - // Show message if any - if let Some((message, message_type)) = &self.message { - let color = match message_type { - MessageType::Success => DashColors::SUCCESS, - MessageType::Error => DashColors::ERROR, - MessageType::Warning => DashColors::WARNING, - MessageType::Info => DashColors::INFO, - }; - ui.colored_label(color, message); - ui.separator(); - } - ScrollArea::vertical().show(ui, |ui| { ui.group(|ui| { // Contact identity @@ -246,8 +235,11 @@ impl ContactInfoEditorScreen { // Check wallet lock status before showing save button let wallet_locked = if let Some(wallet) = &self.selected_wallet { - if let Err(e) = try_open_wallet_no_password(wallet) { - self.message = Some((e, MessageType::Error)); + if !self.wallet_open_attempted { + if let Err(e) = try_open_wallet_no_password(wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + } + self.wallet_open_attempted = true; } wallet_needs_unlock(wallet) } else { @@ -298,21 +290,21 @@ impl ContactInfoEditorScreen { action } - pub fn display_message(&mut self, message: &str, message_type: MessageType) { - self.message = Some((message.to_string(), message_type)); + pub fn display_message(&mut self, _message: &str, _message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. } pub fn display_task_result(&mut self, result: BackendTaskSuccessResult) { self.saving = false; match result { - BackendTaskSuccessResult::Message(msg) => { - self.display_message(&msg, MessageType::Success); + BackendTaskSuccessResult::Message(_msg) => { + // Message display is handled globally by AppState } BackendTaskSuccessResult::DashPayContactsWithInfo(contacts_data) => { self.handle_contacts_result(contacts_data); } _ => { - self.display_message("Contact information updated", MessageType::Success); + // Message display is handled globally by AppState } } } diff --git a/src/ui/dashpay/contact_profile_viewer.rs b/src/ui/dashpay/contact_profile_viewer.rs index 6f72a214f..b61b6ef64 100644 --- a/src/ui/dashpay/contact_profile_viewer.rs +++ b/src/ui/dashpay/contact_profile_viewer.rs @@ -17,7 +17,7 @@ use dash_sdk::platform::Identifier; use egui::{ColorImage, RichText, ScrollArea, TextureHandle, Ui}; use std::collections::HashMap; use std::sync::Arc; -use tracing::warn; +use tracing::error; const PUBLIC_PROFILE_INFO_TEXT: &str = "About Public Profiles:\n\n\ This is the contact's public DashPay profile.\n\n\ @@ -44,7 +44,6 @@ pub struct ContactProfileViewerScreen { pub identity: QualifiedIdentity, pub contact_id: Identifier, profile: Option, - message: Option<(String, MessageType)>, loading: bool, initial_fetch_done: bool, // Private contact info fields @@ -103,7 +102,6 @@ impl ContactProfileViewerScreen { identity, contact_id, profile, - message: None, loading: false, initial_fetch_done, // If we have cached data, don't auto-fetch nickname, @@ -119,7 +117,6 @@ impl ContactProfileViewerScreen { fn fetch_profile(&mut self) -> AppAction { self.loading = true; self.profile = None; // Clear any existing profile - self.message = None; // Clear any existing message let task = BackendTask::DashPayTask(Box::new(DashPayTask::FetchContactProfile { identity: self.identity.clone(), @@ -193,7 +190,7 @@ impl ContactProfileViewerScreen { } } Err(e) => { - warn!("Failed to fetch contact avatar image: {}", e); + error!("Failed to fetch contact avatar image: {}", e); } } }); @@ -225,18 +222,6 @@ impl ContactProfileViewerScreen { ui.separator(); - // Show message if any - if let Some((message, message_type)) = &self.message { - let color = match message_type { - MessageType::Success => DashColors::success_color(dark_mode), - MessageType::Error => DashColors::error_color(dark_mode), - MessageType::Warning => DashColors::warning_color(dark_mode), - MessageType::Info => DashColors::DASH_BLUE, - }; - ui.colored_label(color, message); - ui.separator(); - } - // Loading indicator if self.loading { ui.horizontal(|ui| { @@ -540,16 +525,18 @@ impl ContactProfileViewerScreen { match self.save_private_info() { Ok(_) => { self.editing_private_info = false; - self.message = Some(( - "Private info saved".to_string(), + crate::ui::components::MessageBanner::set_global( + ui.ctx(), + "Private info saved", MessageType::Success, - )); + ); } Err(e) => { - self.message = Some(( + crate::ui::components::MessageBanner::set_global( + ui.ctx(), format!("Failed to save: {}", e), MessageType::Error, - )); + ); } } } @@ -639,15 +626,14 @@ impl ContactProfileViewerScreen { action } - pub fn display_message(&mut self, message: &str, message_type: MessageType) { + pub fn display_message(&mut self, _message: &str, _message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. self.loading = false; - self.message = Some((message.to_string(), message_type)); } pub fn refresh(&mut self) { // Don't auto-fetch on refresh - just clear temporary states self.loading = false; - self.message = None; } pub fn refresh_on_arrival(&mut self) { @@ -742,15 +728,12 @@ impl ScreenLike for ContactProfileViewerScreen { // Note: We don't save to database here - that should only happen // when actually adding them as a contact, not just viewing their profile - - self.message = None; } else { self.profile = None; - self.message = None; // Don't set message here, UI already shows "No profile found" } } - BackendTaskSuccessResult::Message(msg) => { - self.message = Some((msg, MessageType::Info)); + BackendTaskSuccessResult::Message(_msg) => { + // Message display is handled globally by AppState } _ => { // Ignore other results diff --git a/src/ui/dashpay/contact_requests.rs b/src/ui/dashpay/contact_requests.rs index 822d62238..3a63a0fe9 100644 --- a/src/ui/dashpay/contact_requests.rs +++ b/src/ui/dashpay/contact_requests.rs @@ -11,6 +11,7 @@ use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{MessageBanner, ResultBannerExt}; use crate::ui::identities::get_selected_wallet; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::theme::DashColors; @@ -56,13 +57,13 @@ pub struct ContactRequests { selected_identity: Option, selected_identity_string: String, active_tab: RequestTab, - message: Option<(String, MessageType)>, loading: bool, has_fetched_requests: bool, accept_confirmation_dialog: Option<(ConfirmationDialog, ContactRequest)>, reject_confirmation_dialog: Option<(ConfirmationDialog, ContactRequest)>, pub selected_wallet: Option>>, pub wallet_unlock_popup: WalletUnlockPopup, + wallet_open_attempted: bool, /// Structured error for displaying with action buttons error: Option, /// Identity IDs that need profile fetching from Platform @@ -80,13 +81,13 @@ impl ContactRequests { selected_identity: None, selected_identity_string: String::new(), active_tab: RequestTab::Incoming, - message: None, loading: false, has_fetched_requests: false, accept_confirmation_dialog: None, reject_confirmation_dialog: None, selected_wallet: None, wallet_unlock_popup: WalletUnlockPopup::new(), + wallet_open_attempted: false, error: None, pending_profile_fetches: HashSet::new(), }; @@ -103,9 +104,10 @@ impl ContactRequests { .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58); // Get wallet for the selected identity - let mut error_message = None; new_self.selected_wallet = - get_selected_wallet(&identities[0], Some(&app_context), None, &mut error_message); + get_selected_wallet(&identities[0], Some(&app_context), None) + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None); // Load requests from database for this identity new_self.load_requests_from_database(); @@ -131,18 +133,19 @@ impl ContactRequests { .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58); // Update wallet for the newly selected identity - let mut error_message = None; - self.selected_wallet = - get_selected_wallet(id, Some(&self.app_context), None, &mut error_message); + self.selected_wallet = get_selected_wallet(id, Some(&self.app_context), None) + .or_show_error(self.app_context.egui_ctx()) + .unwrap_or(None); + self.wallet_open_attempted = false; } else { self.selected_identity_string.clear(); self.selected_wallet = None; + self.wallet_open_attempted = false; } // Clear the requests when identity changes self.incoming_requests.clear(); self.outgoing_requests.clear(); - self.message = None; self.has_fetched_requests = false; self.pending_profile_fetches.clear(); @@ -438,7 +441,6 @@ impl ContactRequests { // Only fetch if we have a selected identity if let Some(identity) = &self.selected_identity { self.loading = true; - self.message = None; let task = BackendTask::DashPayTask(Box::new(DashPayTask::LoadContactRequests { identity: identity.clone(), @@ -467,7 +469,6 @@ impl ContactRequests { pub fn refresh(&mut self) -> AppAction { // Don't clear requests - preserve loaded state // Only clear temporary states - self.message = None; self.loading = false; // Auto-select first identity if none selected @@ -507,10 +508,6 @@ impl ContactRequests { if let Some(identity) = &self.selected_identity { // Don't mark as accepted yet - wait for backend confirmation self.loading = true; - self.message = Some(( - "Accepting contact request...".to_string(), - MessageType::Info, - )); let task = BackendTask::DashPayTask(Box::new(DashPayTask::AcceptContactRequest { @@ -532,10 +529,6 @@ impl ContactRequests { if response.inner.dialog_response == Some(ConfirmationStatus::Confirmed) { if let Some(identity) = &self.selected_identity { self.loading = true; - self.message = Some(( - "Rejecting contact request...".to_string(), - MessageType::Info, - )); // Don't mark as rejected yet - wait for backend confirmation @@ -582,22 +575,19 @@ impl ContactRequests { // Clear the requests when identity changes self.incoming_requests.clear(); self.outgoing_requests.clear(); - self.message = None; self.has_fetched_requests = false; self.pending_profile_fetches.clear(); // Update wallet for the newly selected identity if let Some(identity) = &self.selected_identity { - let mut error_message = None; - self.selected_wallet = get_selected_wallet( - identity, - Some(&self.app_context), - None, - &mut error_message, - ); + self.selected_wallet = + get_selected_wallet(identity, Some(&self.app_context), None) + .or_show_error(self.app_context.egui_ctx()) + .unwrap_or(None); } else { self.selected_wallet = None; } + self.wallet_open_attempted = false; // Load requests from database for the newly selected identity self.load_requests_from_database(); @@ -646,21 +636,6 @@ impl ContactRequests { ui.separator(); } - // Show regular message if any (non-error) - if let Some((message, message_type)) = &self.message { - let color = match message_type { - MessageType::Success => egui::Color32::DARK_GREEN, - MessageType::Error => egui::Color32::DARK_RED, - MessageType::Warning => DashColors::WARNING, - MessageType::Info => egui::Color32::LIGHT_BLUE, - }; - // Only show error messages here if there's no structured error - if message_type == &MessageType::Error && self.error.is_none() { - ui.colored_label(color, RichText::new(message).strong()); - ui.separator(); - } - } - if self.selected_identity.is_none() { ui.label("Please select an identity to view contact requests"); return action; @@ -729,13 +704,7 @@ impl ContactRequests { if self.loading { ui.horizontal(|ui| { ui.add(egui::widgets::Spinner::default().color(DashColors::DASH_BLUE)); - - // Show specific loading message based on current message - if let Some((msg, _)) = &self.message { - ui.label(msg); - } else { - ui.label("Loading..."); - } + ui.label("Loading..."); }); } else { ScrollArea::vertical().id_salt("incoming_requests_scroll").show(ui, |ui| { @@ -841,8 +810,11 @@ impl ContactRequests { } else { // Check wallet lock status before showing buttons let wallet_locked = if let Some(wallet) = &self.selected_wallet { - if let Err(e) = try_open_wallet_no_password(wallet) { - self.message = Some((e, MessageType::Error)); + if !self.wallet_open_attempted { + if let Err(e) = try_open_wallet_no_password(wallet) { + crate::ui::components::MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + } + self.wallet_open_attempted = true; } wallet_needs_unlock(wallet) } else { @@ -914,13 +886,7 @@ impl ContactRequests { if self.loading { ui.horizontal(|ui| { ui.add(egui::widgets::Spinner::default().color(DashColors::DASH_BLUE)); - - // Show specific loading message based on current message - if let Some((msg, _)) = &self.message { - ui.label(msg); - } else { - ui.label("Loading..."); - } + ui.label("Loading..."); }); } else { ScrollArea::vertical().id_salt("outgoing_requests_scroll").show(ui, |ui| { @@ -1073,23 +1039,17 @@ impl ScreenLike for ContactRequests { } fn display_message(&mut self, message: &str, message_type: MessageType) { - // Clear loading state when displaying any message (including errors) + // Banner display is handled globally by AppState; this is only for side-effects. self.loading = false; - // Check if this is an error about missing keys - if message_type == MessageType::Error { + // TODO(RUST-002): String-based error classification — see #660 + if matches!(message_type, MessageType::Error | MessageType::Warning) { if message.contains("ENCRYPTION key") { self.error = Some(DashPayError::MissingEncryptionKey); - self.message = None; - return; } else if message.contains("DECRYPTION key") { self.error = Some(DashPayError::MissingDecryptionKey); - self.message = None; - return; } } - - self.message = Some((message.to_string(), message_type)); } fn display_task_result(&mut self, result: BackendTaskSuccessResult) { @@ -1231,30 +1191,32 @@ impl ScreenLike for ContactRequests { BackendTaskSuccessResult::DashPayContactRequestAccepted(request_id) => { // Mark as accepted only after successful backend operation self.accepted_requests.insert(request_id); - self.message = Some(( - "Contact request accepted successfully".to_string(), + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Contact request accepted successfully", MessageType::Success, - )); + ); } BackendTaskSuccessResult::DashPayContactRequestRejected(request_id) => { // Mark as rejected only after successful backend operation self.rejected_requests.insert(request_id); - self.message = Some(("Contact request rejected".to_string(), MessageType::Success)); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Contact request rejected", + MessageType::Success, + ); } BackendTaskSuccessResult::DashPayContactAlreadyEstablished(_) => { - self.message = Some(("Contact already established".to_string(), MessageType::Info)); + // Message display is handled globally by AppState } BackendTaskSuccessResult::Message(msg) => { // Check if this is an error message about missing keys if msg.contains("ENCRYPTION key") { self.error = Some(DashPayError::MissingEncryptionKey); - self.message = None; } else if msg.contains("DECRYPTION key") { self.error = Some(DashPayError::MissingDecryptionKey); - self.message = None; - } else { - self.message = Some((msg, MessageType::Success)); } + // Other messages are handled globally by AppState } _ => { // Ignore other results diff --git a/src/ui/dashpay/contacts_list.rs b/src/ui/dashpay/contacts_list.rs index 34cc13a76..b851aa627 100644 --- a/src/ui/dashpay/contacts_list.rs +++ b/src/ui/dashpay/contacts_list.rs @@ -4,6 +4,7 @@ use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; use crate::model::qualified_identity::QualifiedIdentity; +use crate::ui::components::ResultBannerExt; use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::wallet_unlock_popup::WalletUnlockResult; use crate::ui::dashpay::contact_requests::ContactRequests; @@ -1065,13 +1066,11 @@ impl ScreenLike for ContactsList { // Clear all existing contacts for this identity from database first // This prevents stale contacts from persisting - if let Err(e) = self - .app_context + self.app_context .db .clear_dashpay_contacts(&owner_id, &network_str) - { - tracing::warn!("Failed to clear dashpay contacts from database: {}", e); - } + .or_show_error(self.app_context.egui_ctx()) + .ok(); // Convert ContactData to Contact structs and save to database for contact_data in contacts_data { @@ -1103,33 +1102,34 @@ impl ScreenLike for ContactsList { self.contacts.insert(contact_data.identity_id, contact); // Save to database - if let Err(e) = self.app_context.db.save_dashpay_contact( - &owner_id, - &contact_data.identity_id, - &network_str, - contact_data.username.as_deref(), - contact_data.display_name.as_deref(), - contact_data.avatar_url.as_deref(), - None, // public_message - not yet fetched - "accepted", // Only accepted contacts are returned from load_contacts - ) { - tracing::warn!("Failed to save dashpay contact to database: {}", e); - } - - // Save private info if present - if let Some(nickname) = &contact_data.nickname - && let Err(e) = self.app_context.db.save_contact_private_info( + self.app_context + .db + .save_dashpay_contact( &owner_id, &contact_data.identity_id, - nickname, - &contact_data.note.unwrap_or_default(), - contact_data.is_hidden, + &network_str, + contact_data.username.as_deref(), + contact_data.display_name.as_deref(), + contact_data.avatar_url.as_deref(), + None, // public_message - not yet fetched + "accepted", // Only accepted contacts are returned from load_contacts ) - { - tracing::warn!( - "Failed to save contact private info to database: {}", - e - ); + .or_show_error(self.app_context.egui_ctx()) + .ok(); + + // Save private info if present + if let Some(nickname) = &contact_data.nickname { + self.app_context + .db + .save_contact_private_info( + &owner_id, + &contact_data.identity_id, + nickname, + &contact_data.note.unwrap_or_default(), + contact_data.is_hidden, + ) + .or_show_error(self.app_context.egui_ctx()) + .ok(); } } } else { diff --git a/src/ui/dashpay/profile_screen.rs b/src/ui/dashpay/profile_screen.rs index a97aac44b..fdd62eaba 100644 --- a/src/ui/dashpay/profile_screen.rs +++ b/src/ui/dashpay/profile_screen.rs @@ -13,6 +13,7 @@ use crate::ui::components::info_popup::InfoPopup; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{MessageBanner, ResultBannerExt}; use crate::ui::identities::get_selected_wallet; use crate::ui::theme::DashColors; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; @@ -84,7 +85,6 @@ pub struct ProfileScreen { edit_display_name: String, edit_bio: String, edit_avatar_url: String, - message: Option<(String, MessageType)>, loading: bool, saving: bool, // Track if we're saving vs loading profile_load_attempted: bool, @@ -101,6 +101,7 @@ pub struct ProfileScreen { show_avatar_url_popup: bool, // Show avatar URL when clicking on avatar in view mode selected_wallet: Option>>, wallet_unlock_popup: WalletUnlockPopup, + wallet_open_attempted: bool, show_success: bool, was_creating_new: bool, // Track if we were creating vs updating confirmation_dialog: Option, @@ -117,7 +118,6 @@ impl ProfileScreen { edit_display_name: String::new(), edit_bio: String::new(), edit_avatar_url: String::new(), - message: None, loading: false, saving: false, profile_load_attempted: false, @@ -134,6 +134,7 @@ impl ProfileScreen { show_avatar_url_popup: false, selected_wallet: None, wallet_unlock_popup: WalletUnlockPopup::new(), + wallet_open_attempted: false, show_success: false, was_creating_new: false, confirmation_dialog: None, @@ -202,13 +203,10 @@ impl ProfileScreen { ); // Get wallet for the selected identity - let mut error_message = None; - new_self.selected_wallet = get_selected_wallet( - &identities[selected_idx], - Some(&app_context), - None, - &mut error_message, - ); + new_self.selected_wallet = + get_selected_wallet(&identities[selected_idx], Some(&app_context), None) + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None); // Load profile from database for this identity new_self.load_profile_from_database(); @@ -346,9 +344,6 @@ impl ProfileScreen { // This prevents stuck loading states self.loading = false; - // Clear any old messages - self.message = None; - // Auto-select first identity if none selected if self.selected_identity.is_none() && let Ok(identities) = self.app_context.load_local_qualified_identities() @@ -392,14 +387,17 @@ impl ProfileScreen { self.editing = true; self.has_unsaved_changes = false; self.validation_errors.clear(); - self.message = None; } fn save_profile(&mut self) -> AppAction { self.validate_profile(); if !self.is_valid() { - self.display_message(&self.validation_errors[0].message(), MessageType::Error); + MessageBanner::set_global( + self.app_context.egui_ctx(), + self.validation_errors[0].message(), + MessageType::Error, + ); return AppAction::None; } @@ -437,7 +435,11 @@ impl ProfileScreen { }, ))) } else { - self.display_message("No identity selected", MessageType::Error); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "No identity selected", + MessageType::Error, + ); AppAction::None } } @@ -449,7 +451,6 @@ impl ProfileScreen { self.edit_avatar_url.clear(); self.validation_errors.clear(); self.has_unsaved_changes = false; - self.message = None; } /// Load avatar texture from network (fetches bytes and processes them) @@ -626,22 +627,19 @@ impl ProfileScreen { self.editing = false; self.validation_errors.clear(); self.has_unsaved_changes = false; - self.message = None; self.avatar_loading = false; // Don't clear avatar_textures - they're keyed by URL so can be reused // Update wallet for the newly selected identity if let Some(identity) = &self.selected_identity { - let mut error_message = None; - self.selected_wallet = get_selected_wallet( - identity, - Some(&self.app_context), - None, - &mut error_message, - ); + self.selected_wallet = + get_selected_wallet(identity, Some(&self.app_context), None) + .or_show_error(self.app_context.egui_ctx()) + .unwrap_or(None); } else { self.selected_wallet = None; } + self.wallet_open_attempted = false; // Load profile from database for the newly selected identity self.load_profile_from_database(); @@ -656,18 +654,6 @@ impl ProfileScreen { return super::render_no_identities_card(ui, &self.app_context); } - // Show message if any - if let Some((message, message_type)) = &self.message { - let color = match message_type { - MessageType::Success => egui::Color32::DARK_GREEN, - MessageType::Error => egui::Color32::DARK_RED, - MessageType::Warning => DashColors::WARNING, - MessageType::Info => egui::Color32::LIGHT_BLUE, - }; - ui.colored_label(color, message); - ui.separator(); - } - if self.selected_identity.is_none() { ui.label("Please select an identity to view or edit profile"); return action; @@ -884,8 +870,11 @@ impl ProfileScreen { // Check wallet lock status before showing save button let wallet_locked = if let Some(wallet) = &self.selected_wallet { - if let Err(e) = try_open_wallet_no_password(wallet) { - self.message = Some((e, MessageType::Error)); + if !self.wallet_open_attempted { + if let Err(e) = try_open_wallet_no_password(wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + } + self.wallet_open_attempted = true; } wallet_needs_unlock(wallet) } else { @@ -1348,7 +1337,8 @@ impl ProfileScreen { ui.horizontal(|ui| { if ui.button("Copy URL").clicked() { ui.ctx().copy_text(avatar_url.clone()); - self.display_message( + MessageBanner::set_global( + ui.ctx(), "Avatar URL copied to clipboard", MessageType::Info, ); @@ -1394,10 +1384,9 @@ impl ProfileScreen { action } - pub fn display_message(&mut self, message: &str, message_type: MessageType) { - self.message = Some((message.to_string(), message_type)); - // Clear loading/saving states on error - if message_type == MessageType::Error { + pub fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { self.loading = false; self.saving = false; } diff --git a/src/ui/dashpay/profile_search.rs b/src/ui/dashpay/profile_search.rs index a22810f4e..9f10f23ac 100644 --- a/src/ui/dashpay/profile_search.rs +++ b/src/ui/dashpay/profile_search.rs @@ -34,7 +34,6 @@ pub struct ProfileSearchScreen { pub app_context: Arc, search_query: String, search_results: Vec, - message: Option<(String, MessageType)>, loading: bool, has_searched: bool, // Track if a search has been performed show_info_popup: bool, @@ -46,16 +45,20 @@ impl ProfileSearchScreen { app_context, search_query: String::new(), search_results: Vec::new(), - message: None, loading: false, has_searched: false, show_info_popup: false, } } - fn search_profiles(&mut self) -> AppAction { + fn search_profiles(&mut self, ctx: &egui::Context) -> AppAction { if self.search_query.trim().is_empty() { - self.display_message("Please enter a search term", MessageType::Error); + self.loading = false; + crate::ui::components::MessageBanner::set_global( + ctx, + "Please enter a search term", + MessageType::Error, + ); return AppAction::None; } @@ -70,14 +73,15 @@ impl ProfileSearchScreen { AppAction::BackendTask(task) } - fn view_profile(&mut self, identity_id: Identifier) -> AppAction { + fn view_profile(&mut self, ctx: &egui::Context, identity_id: Identifier) -> AppAction { // Use any available identity for viewing (just needed for context) let identities = self .app_context .load_local_qualified_identities() .unwrap_or_default(); if identities.is_empty() { - self.display_message( + crate::ui::components::MessageBanner::set_global( + ctx, "No identities available. Please load an identity first.", MessageType::Error, ); @@ -117,18 +121,6 @@ impl ProfileSearchScreen { ui.separator(); - // Show message if any - if let Some((message, message_type)) = &self.message { - let color = match message_type { - MessageType::Success => DashColors::success_color(dark_mode), - MessageType::Error => DashColors::error_color(dark_mode), - MessageType::Warning => DashColors::warning_color(dark_mode), - MessageType::Info => DashColors::DASH_BLUE, - }; - ui.colored_label(color, message); - ui.separator(); - } - ScrollArea::vertical().show(ui, |ui| { // Search section ui.horizontal(|ui| { @@ -142,12 +134,12 @@ impl ProfileSearchScreen { // Trigger search on Enter key if response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { - action = self.search_profiles(); + action = self.search_profiles(ui.ctx()); } }); if ui.button("Search").clicked() { - action = self.search_profiles(); + action = self.search_profiles(ui.ctx()); } }); @@ -233,7 +225,7 @@ impl ProfileSearchScreen { egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.button("View Profile").clicked() { - action = self.view_profile(result.identity_id); + action = self.view_profile(ui.ctx(), result.identity_id); } if ui.button("Add Contact").clicked() { action = self.add_contact(result.identity_id); @@ -258,9 +250,9 @@ impl ProfileSearchScreen { action } - pub fn display_message(&mut self, message: &str, message_type: MessageType) { + pub fn display_message(&mut self, _message: &str, _message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. self.loading = false; - self.message = Some((message.to_string(), message_type)); } } @@ -302,7 +294,6 @@ impl ScreenLike for ProfileSearchScreen { self.search_query.clear(); self.search_results.clear(); self.has_searched = false; - self.message = None; action = AppAction::None; // Consume the action } @@ -371,8 +362,8 @@ impl ScreenLike for ProfileSearchScreen { self.search_results.push(search_result); } } - BackendTaskSuccessResult::Message(msg) => { - self.message = Some((msg, MessageType::Info)); + BackendTaskSuccessResult::Message(_msg) => { + // Message display is handled globally by AppState } _ => { // Ignore other results diff --git a/src/ui/dashpay/qr_code_generator.rs b/src/ui/dashpay/qr_code_generator.rs index ac76a407e..412c690f8 100644 --- a/src/ui/dashpay/qr_code_generator.rs +++ b/src/ui/dashpay/qr_code_generator.rs @@ -12,6 +12,7 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{MessageBanner, ResultBannerExt}; use crate::ui::dashpay::dashpay_screen::DashPaySubscreen; use crate::ui::identities::funding_common::generate_qr_code_image; use crate::ui::identities::get_selected_wallet; @@ -42,11 +43,11 @@ pub struct QRCodeGeneratorScreen { account_index: String, validity_hours: String, generated_qr_data: Option, - message: Option<(String, MessageType)>, show_info_popup: bool, show_advanced_options: bool, selected_wallet: Option>>, wallet_unlock_popup: WalletUnlockPopup, + wallet_open_attempted: bool, } impl QRCodeGeneratorScreen { @@ -58,11 +59,11 @@ impl QRCodeGeneratorScreen { account_index: "0".to_string(), validity_hours: "24".to_string(), generated_qr_data: None, - message: None, show_info_popup: false, show_advanced_options: false, selected_wallet: None, wallet_unlock_popup: WalletUnlockPopup::new(), + wallet_open_attempted: false, }; // Auto-select first identity on creation if available @@ -77,9 +78,10 @@ impl QRCodeGeneratorScreen { identities[0].identity.id().to_string(Encoding::Base58); // Get wallet for the selected identity - let mut error_message = None; new_self.selected_wallet = - get_selected_wallet(&identities[0], Some(&app_context), None, &mut error_message); + get_selected_wallet(&identities[0], Some(&app_context), None) + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None); } new_self @@ -90,7 +92,11 @@ impl QRCodeGeneratorScreen { let account_idx = match self.account_index.parse::() { Ok(v) => v, Err(_) => { - self.display_message("Invalid account index number", MessageType::Error); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Invalid account index number", + MessageType::Error, + ); return; } }; @@ -98,7 +104,8 @@ impl QRCodeGeneratorScreen { let validity = match self.validity_hours.parse::() { Ok(v) if v > 0 && v <= 720 => v, // Max 30 days _ => { - self.display_message( + MessageBanner::set_global( + self.app_context.egui_ctx(), "Validity hours must be between 1 and 720", MessageType::Error, ); @@ -110,17 +117,26 @@ impl QRCodeGeneratorScreen { Ok(proof_data) => { let qr_string = proof_data.to_qr_string(); self.generated_qr_data = Some(qr_string); - self.display_message("QR code generated successfully", MessageType::Success); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "QR code generated successfully", + MessageType::Success, + ); } Err(e) => { - self.display_message( - &format!("Failed to generate QR code: {}", e), + MessageBanner::set_global( + self.app_context.egui_ctx(), + format!("Failed to generate QR code: {}", e), MessageType::Error, ); } } } else { - self.display_message("Please select an identity first", MessageType::Error); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Please select an identity first", + MessageType::Error, + ); } } @@ -145,18 +161,6 @@ impl QRCodeGeneratorScreen { ui.separator(); - // Show message if any - if let Some((message, message_type)) = &self.message { - let color = match message_type { - MessageType::Success => DashColors::success_color(dark_mode), - MessageType::Error => DashColors::error_color(dark_mode), - MessageType::Warning => DashColors::warning_color(dark_mode), - MessageType::Info => DashColors::DASH_BLUE, - }; - ui.colored_label(color, message); - ui.separator(); - } - // Identity selector let identities = self .app_context @@ -201,19 +205,19 @@ impl QRCodeGeneratorScreen { if response.changed() { // Update wallet for the newly selected identity if let Some(identity) = &self.selected_identity { - let mut error_message = None; self.selected_wallet = get_selected_wallet( identity, Some(&self.app_context), None, - &mut error_message, - ); + ) + .or_show_error(self.app_context.egui_ctx()) + .unwrap_or(None); } else { self.selected_wallet = None; } + self.wallet_open_attempted = false; // Clear generated QR code when identity changes self.generated_qr_data = None; - self.message = None; } }); ui.end_row(); @@ -264,8 +268,11 @@ impl QRCodeGeneratorScreen { // Check wallet lock status before showing generate button let wallet_locked = if let Some(wallet) = &self.selected_wallet { - if let Err(e) = try_open_wallet_no_password(wallet) { - self.message = Some((e, MessageType::Error)); + if !self.wallet_open_attempted { + if let Err(e) = try_open_wallet_no_password(wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + } + self.wallet_open_attempted = true; } wallet_needs_unlock(wallet) } else { @@ -293,7 +300,6 @@ impl QRCodeGeneratorScreen { if self.generated_qr_data.is_some() && ui.button("Clear").clicked() { self.generated_qr_data = None; - self.message = None; } }); } @@ -368,7 +374,7 @@ impl QRCodeGeneratorScreen { } if show_copied_message { - self.display_message("Copied to clipboard", MessageType::Success); + MessageBanner::set_global(ui.ctx(), "Copied to clipboard", MessageType::Success); } }); @@ -387,8 +393,8 @@ impl QRCodeGeneratorScreen { action } - pub fn display_message(&mut self, message: &str, message_type: MessageType) { - self.message = Some((message.to_string(), message_type)); + pub fn display_message(&mut self, _message: &str, _message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. } } @@ -435,7 +441,7 @@ impl ScreenLike for QRCodeGeneratorScreen { action } - fn display_message(&mut self, message: &str, message_type: MessageType) { - self.display_message(message, message_type); + fn display_message(&mut self, _message: &str, _message_type: MessageType) { + // Banner display is handled globally by AppState; no side-effects needed. } } diff --git a/src/ui/dashpay/qr_scanner.rs b/src/ui/dashpay/qr_scanner.rs index 5eafeb4a6..cea322cf8 100644 --- a/src/ui/dashpay/qr_scanner.rs +++ b/src/ui/dashpay/qr_scanner.rs @@ -13,9 +13,9 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{MessageBanner, ResultBannerExt}; use crate::ui::dashpay::dashpay_screen::DashPaySubscreen; use crate::ui::identities::get_selected_wallet; -use crate::ui::theme::DashColors; use crate::ui::{MessageType, RootScreenType, ScreenLike}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; @@ -29,10 +29,10 @@ pub struct QRScannerScreen { selected_identity_string: String, qr_data_input: String, parsed_qr_data: Option, - message: Option<(String, MessageType)>, sending: bool, selected_wallet: Option>>, wallet_unlock_popup: WalletUnlockPopup, + wallet_open_attempted: bool, } impl QRScannerScreen { @@ -43,27 +43,40 @@ impl QRScannerScreen { selected_identity_string: String::new(), qr_data_input: String::new(), parsed_qr_data: None, - message: None, sending: false, selected_wallet: None, wallet_unlock_popup: WalletUnlockPopup::new(), + wallet_open_attempted: false, } } fn parse_qr_code(&mut self) { if self.qr_data_input.is_empty() { - self.display_message("Please enter QR code data", MessageType::Error); + self.parsed_qr_data = None; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Please enter QR code data", + MessageType::Error, + ); return; } match AutoAcceptProofData::from_qr_string(&self.qr_data_input) { Ok(data) => { self.parsed_qr_data = Some(data); - self.display_message("QR code parsed successfully", MessageType::Success); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "QR code parsed successfully", + MessageType::Success, + ); } Err(e) => { self.parsed_qr_data = None; - self.display_message(&format!("Invalid QR code: {}", e), MessageType::Error); + MessageBanner::set_global( + self.app_context.egui_ctx(), + format!("Invalid QR code: {}", e), + MessageType::Error, + ); } } } @@ -84,7 +97,11 @@ impl QRScannerScreen { ) { Some(key) => key, None => { - self.display_message("No suitable signing key found. This operation requires a ECDSA_SECP256K1 AUTHENTICATION key.", MessageType::Error); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "No suitable signing key found. This operation requires a ECDSA_SECP256K1 AUTHENTICATION key.", + MessageType::Error, + ); return AppAction::None; } }; @@ -106,10 +123,18 @@ impl QRScannerScreen { return AppAction::BackendTask(task); } else { - self.display_message("Please parse a QR code first", MessageType::Error); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Please parse a QR code first", + MessageType::Error, + ); } } else { - self.display_message("Please select an identity", MessageType::Error); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Please select an identity", + MessageType::Error, + ); } AppAction::None @@ -117,24 +142,11 @@ impl QRScannerScreen { pub fn render(&mut self, ui: &mut Ui) -> AppAction { let mut action = AppAction::None; - let dark_mode = ui.ctx().style().visuals.dark_mode; // Header ui.heading("Scan Contact QR Code"); ui.add_space(10.0); - // Show message if any - if let Some((message, message_type)) = &self.message { - let color = match message_type { - MessageType::Success => DashColors::success_color(dark_mode), - MessageType::Error => DashColors::error_color(dark_mode), - MessageType::Warning => DashColors::warning_color(dark_mode), - MessageType::Info => DashColors::DASH_BLUE, - }; - ui.colored_label(color, message); - ui.add_space(10.0); - } - // Identity selector let identities = self .app_context @@ -174,16 +186,17 @@ impl QRScannerScreen { let new_identity_id = self.selected_identity.as_ref().map(|i| i.identity.id()); if prev_identity_id != new_identity_id { if let Some(identity) = &self.selected_identity { - let mut error_message = None; self.selected_wallet = get_selected_wallet( identity, Some(&self.app_context), None, - &mut error_message, - ); + ) + .or_show_error(self.app_context.egui_ctx()) + .unwrap_or(None); } else { self.selected_wallet = None; } + self.wallet_open_attempted = false; } }); @@ -210,7 +223,6 @@ impl QRScannerScreen { if ui.button("Clear").clicked() { self.qr_data_input.clear(); self.parsed_qr_data = None; - self.message = None; } }); }); @@ -247,8 +259,11 @@ impl QRScannerScreen { // Check wallet lock status before showing send button let wallet_locked = if let Some(wallet) = &self.selected_wallet { - if let Err(e) = try_open_wallet_no_password(wallet) { - self.message = Some((e, MessageType::Error)); + if !self.wallet_open_attempted { + if let Err(e) = try_open_wallet_no_password(wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + } + self.wallet_open_attempted = true; } wallet_needs_unlock(wallet) } else { @@ -299,22 +314,18 @@ impl QRScannerScreen { action } - pub fn display_message(&mut self, message: &str, message_type: MessageType) { - self.message = Some((message.to_string(), message_type)); + pub fn display_message(&mut self, _message: &str, _message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. } pub fn display_task_result(&mut self, result: BackendTaskSuccessResult) { self.sending = false; - match result { - BackendTaskSuccessResult::Message(msg) => { - self.display_message(&msg, MessageType::Success); - // Clear the form on success - self.qr_data_input.clear(); - self.parsed_qr_data = None; - } - _ => { - self.display_message("Contact request sent successfully", MessageType::Success); - } + if let BackendTaskSuccessResult::DashPayContactRequestSent(_) + | BackendTaskSuccessResult::DashPayContactAlreadyEstablished(_) = result + { + // Clear the form on success + self.qr_data_input.clear(); + self.parsed_qr_data = None; } } } @@ -359,8 +370,8 @@ impl ScreenLike for QRScannerScreen { action } - fn display_message(&mut self, message: &str, message_type: MessageType) { - self.display_message(message, message_type); + fn display_message(&mut self, _message: &str, _message_type: MessageType) { + // Banner display is handled globally by AppState; no side-effects needed. } fn display_task_result(&mut self, result: BackendTaskSuccessResult) { diff --git a/src/ui/dashpay/send_payment.rs b/src/ui/dashpay/send_payment.rs index f3416a09b..c3ca3d169 100644 --- a/src/ui/dashpay/send_payment.rs +++ b/src/ui/dashpay/send_payment.rs @@ -5,6 +5,7 @@ use crate::context::AppContext; use crate::model::amount::Amount; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; +use crate::ui::components::MessageBanner; use crate::ui::components::amount_input::AmountInput; use crate::ui::components::dashpay_subscreen_chooser_panel::add_dashpay_subscreen_chooser_panel; use crate::ui::components::identity_selector::IdentitySelector; @@ -42,7 +43,6 @@ pub struct SendPaymentScreen { amount_input: Option, amount: Amount, memo: String, - message: Option<(String, MessageType)>, sending: bool, show_info_popup: bool, payment_success: bool, @@ -50,6 +50,7 @@ pub struct SendPaymentScreen { // Wallet unlock selected_wallet: Option>>, wallet_unlock_popup: WalletUnlockPopup, + wallet_open_attempted: bool, } impl SendPaymentScreen { @@ -69,13 +70,13 @@ impl SendPaymentScreen { amount_input: None, amount: Amount::new_dash(0.0), memo: String::new(), - message: None, sending: false, show_info_popup: false, payment_success: false, tx_id: None, selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), + wallet_open_attempted: false, } } @@ -103,7 +104,11 @@ impl SendPaymentScreen { fn send_payment(&mut self) -> AppAction { // Validate amount if self.amount.value() == 0 { - self.display_message("Please enter an amount", MessageType::Error); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Please enter an amount", + MessageType::Error, + ); return AppAction::None; } @@ -124,7 +129,7 @@ impl SendPaymentScreen { }; if let Err(e) = wallet_check { - self.display_message(&e, MessageType::Error); + MessageBanner::set_global(self.app_context.egui_ctx(), &e, MessageType::Error); return AppAction::None; } @@ -132,7 +137,11 @@ impl SendPaymentScreen { let amount_dash = match self.amount.dash_to_duffs() { Ok(duffs) => duffs as f64 / 100_000_000.0, Err(e) => { - self.display_message(&format!("Invalid amount: {}", e), MessageType::Error); + MessageBanner::set_global( + self.app_context.egui_ctx(), + format!("Invalid amount: {}", e), + MessageType::Error, + ); return AppAction::None; } }; @@ -195,33 +204,19 @@ impl SendPaymentScreen { ui.separator(); - // Show message if any - if let Some((message, message_type)) = &self.message { - let color = match message_type { - MessageType::Success => egui::Color32::DARK_GREEN, - MessageType::Error => egui::Color32::DARK_RED, - MessageType::Warning => { - DashColors::warning_color(ui.ctx().style().visuals.dark_mode) - } - MessageType::Info => egui::Color32::LIGHT_BLUE, - }; - ui.colored_label(color, message); - ui.separator(); - } - // Check wallet unlock - let (wallet_open_error, needs_unlock) = if let Some(wallet) = &self.selected_wallet { - let open_err = try_open_wallet_no_password(wallet).err(); - let needs = wallet_needs_unlock(wallet); - (open_err, needs) + let needs_unlock = if let Some(wallet) = &self.selected_wallet { + if !self.wallet_open_attempted { + if let Err(e) = try_open_wallet_no_password(wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + } + self.wallet_open_attempted = true; + } + wallet_needs_unlock(wallet) } else { - (None, false) + false }; - if let Some(e) = wallet_open_error { - self.display_message(&e, MessageType::Error); - } - if needs_unlock { ui.add_space(10.0); ui.colored_label( @@ -369,7 +364,8 @@ impl SendPaymentScreen { if ui.add_enabled(send_enabled, send_button).clicked() { if self.memo.len() > 100 { - self.display_message( + MessageBanner::set_global( + ui.ctx(), "Memo must be 100 characters or less", MessageType::Error, ); @@ -389,8 +385,8 @@ impl SendPaymentScreen { action } - pub fn display_message(&mut self, message: &str, message_type: MessageType) { - self.message = Some((message.to_string(), message_type)); + pub fn display_message(&mut self, _message: &str, _message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. } } @@ -452,21 +448,16 @@ impl ScreenLike for SendPaymentScreen { action } - fn display_message(&mut self, message: &str, message_type: MessageType) { + fn display_message(&mut self, _message: &str, _message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. self.sending = false; - self.message = Some((message.to_string(), message_type)); } fn display_task_result(&mut self, result: BackendTaskSuccessResult) { self.sending = false; - if let BackendTaskSuccessResult::DashPayPaymentSent(recipient, address, amount) = result { - // Extract txid from the address (or we could modify the result to include it) + if let BackendTaskSuccessResult::DashPayPaymentSent(_recipient, address, _amount) = result { self.payment_success = true; self.tx_id = Some(format!("Sent to {}", address)); - self.message = Some(( - format!("Payment of {} DASH sent to {}", amount, recipient), - MessageType::Success, - )); } } } @@ -477,7 +468,6 @@ pub struct PaymentHistory { selected_identity: Option, selected_identity_string: String, payments: Vec, - message: Option<(String, MessageType)>, loading: bool, has_searched: bool, } @@ -499,7 +489,6 @@ impl PaymentHistory { selected_identity: None, selected_identity_string: String::new(), payments: Vec::new(), - message: None, loading: false, has_searched: false, }; @@ -587,7 +576,6 @@ impl PaymentHistory { pub fn trigger_fetch_payment_history(&mut self) -> AppAction { if let Some(identity) = &self.selected_identity { self.loading = true; - self.message = Some(("Loading payment history...".to_string(), MessageType::Info)); let task = BackendTask::DashPayTask(Box::new(DashPayTask::LoadPaymentHistory { identity: identity.clone(), @@ -601,7 +589,6 @@ impl PaymentHistory { pub fn refresh(&mut self) { // Don't clear if we have data, just clear temporary states - self.message = None; self.loading = false; // Auto-select first identity if none selected @@ -662,20 +649,6 @@ impl PaymentHistory { return super::render_no_identities_card(ui, &self.app_context); } - // Show message if any - if let Some((message, message_type)) = &self.message { - let color = match message_type { - MessageType::Success => egui::Color32::DARK_GREEN, - MessageType::Error => egui::Color32::DARK_RED, - MessageType::Warning => { - DashColors::warning_color(ui.ctx().style().visuals.dark_mode) - } - MessageType::Info => egui::Color32::LIGHT_BLUE, - }; - ui.colored_label(color, message); - ui.separator(); - } - if self.selected_identity.is_none() { let dark_mode = ui.ctx().style().visuals.dark_mode; ui.label( @@ -812,8 +785,8 @@ impl PaymentHistory { action } - pub fn display_message(&mut self, message: &str, message_type: MessageType) { - self.message = Some((message.to_string(), message_type)); + pub fn display_message(&mut self, _message: &str, _message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. } pub fn display_task_result(&mut self, result: BackendTaskSuccessResult) { @@ -888,9 +861,6 @@ impl PaymentHistory { self.payments.push(payment); } } - - // Don't show message - let the UI handle empty state - self.message = None; } _ => { // Ignore other results diff --git a/src/ui/dpns/dpns_contested_names_screen.rs b/src/ui/dpns/dpns_contested_names_screen.rs index e749959af..48bf268b2 100644 --- a/src/ui/dpns/dpns_contested_names_screen.rs +++ b/src/ui/dpns/dpns_contested_names_screen.rs @@ -23,6 +23,7 @@ use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::{StyledButton, island_central_panel}; use crate::ui::components::tools_subscreen_chooser_panel::add_tools_subscreen_chooser_panel; use crate::ui::components::top_panel::add_top_panel; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::identities::register_dpns_name_screen::RegisterDpnsNameSource; use crate::ui::theme::DashColors; use crate::ui::{BackendTaskSuccessResult, MessageType, RootScreenType, ScreenLike, ScreenType}; @@ -74,7 +75,7 @@ pub enum ScheduledVoteCastingStatus { #[derive(PartialEq)] pub enum VoteHandlingStatus { NotStarted, - CastingVotes(u64), + CastingVotes, SchedulingVotes, Completed, Failed(String), @@ -82,7 +83,7 @@ pub enum VoteHandlingStatus { #[derive(PartialEq)] pub enum RefreshingStatus { - Refreshing(u64), + Refreshing, NotRefreshing, } @@ -117,7 +118,6 @@ pub struct DPNSScreen { pub scheduled_vote_cast_in_progress: bool, pub selected_votes: Vec, pub app_context: Arc, - message: Option<(String, MessageType, DateTime)>, pending_backend_task: Option, /// Sorting @@ -130,12 +130,14 @@ pub struct DPNSScreen { /// Which sub-screen is active: Active contests, Past, Owned, or Scheduled pub dpns_subscreen: DPNSSubscreen, refreshing_status: RefreshingStatus, + refresh_banner: Option, /// Selected vote handling show_bulk_schedule_popup: bool, bulk_identity_options: Vec, bulk_schedule_message: Option<(MessageType, String)>, bulk_vote_handling_status: VoteHandlingStatus, + vote_banner: Option, set_all_option: VoteOption, } @@ -188,7 +190,6 @@ impl DPNSScreen { scheduled_votes: scheduled_votes_with_status, selected_votes: Vec::new(), app_context: app_context.clone(), - message: None, sort_column: SortColumn::ContestedName, sort_order: SortOrder::Ascending, active_filter_term: String::new(), @@ -198,33 +199,18 @@ impl DPNSScreen { pending_backend_task: None, dpns_subscreen, refreshing_status: RefreshingStatus::NotRefreshing, + refresh_banner: None, // Vote handling show_bulk_schedule_popup: false, bulk_identity_options, bulk_schedule_message: None, bulk_vote_handling_status: VoteHandlingStatus::NotStarted, + vote_banner: None, set_all_option: VoteOption::CastNow, } } - // --------------------------- - // Error handling - // --------------------------- - fn dismiss_message(&mut self) { - self.message = None; - } - - fn check_error_expiration(&mut self) { - if let Some((_, _, timestamp)) = &self.message { - let now = Utc::now(); - let elapsed = now.signed_duration_since(*timestamp); - if elapsed.num_seconds() >= 10 { - self.dismiss_message(); - } - } - } - // --------------------------- // Sorting // --------------------------- @@ -308,12 +294,10 @@ impl DPNSScreen { ui.label(RichText::new("Please check back later or try refreshing the list.").color(DashColors::text_primary(dark_mode))); ui.add_space(20.0); if StyledButton::primary("Refresh").show(ui).clicked() { - if let RefreshingStatus::Refreshing(_) = self.refreshing_status { + if let RefreshingStatus::Refreshing = self.refreshing_status { app_action = AppAction::None; } else { - let now = Utc::now().timestamp() as u64; - self.refreshing_status = RefreshingStatus::Refreshing(now); - self.message = None; // Clear any existing message + self.refreshing_status = RefreshingStatus::Refreshing; match self.dpns_subscreen { DPNSSubscreen::Active | DPNSSubscreen::Past => { app_action = AppAction::BackendTask(BackendTask::ContestedResourceTask( @@ -977,13 +961,15 @@ impl DPNSScreen { .db .set_identity_alias(&identifier, Some(&alias_with_suffix)) { - self.display_message( - &format!("Failed to set alias: {}", e), + MessageBanner::set_global( + ui.ctx(), + format!("Failed to set alias: {}", e), MessageType::Error, ); } else { - self.display_message( - &format!( + MessageBanner::set_global( + ui.ctx(), + format!( "Alias set to '{}' for identity {}", alias_with_suffix, identifier.to_string(Encoding::Base58) @@ -1555,6 +1541,13 @@ impl DPNSScreen { .corner_radius(3.0); if ui.add(button).clicked() { action = self.bulk_apply_votes(); + if self.bulk_vote_handling_status == VoteHandlingStatus::CastingVotes { + self.vote_banner.take_and_clear(); + let handle = + MessageBanner::set_global(ui.ctx(), "Casting votes...", MessageType::Info); + handle.with_elapsed(); + self.vote_banner = Some(handle); + } } ui.add_space(5.0); @@ -1563,20 +1556,15 @@ impl DPNSScreen { self.show_bulk_schedule_popup = false; self.bulk_schedule_message = None; self.bulk_vote_handling_status = VoteHandlingStatus::NotStarted; + self.vote_banner.take_and_clear(); } // Handle status ui.add_space(10.0); match &self.bulk_vote_handling_status { VoteHandlingStatus::NotStarted => {} - VoteHandlingStatus::CastingVotes(start_time) => { - let now = Utc::now().timestamp() as u64; - let elapsed = now - start_time; - let dark_mode = ui.ctx().style().visuals.dark_mode; - ui.label( - RichText::new(format!("Casting votes... Time taken so far: {}", elapsed)) - .color(DashColors::text_primary(dark_mode)), - ); + VoteHandlingStatus::CastingVotes => { + // Elapsed time is shown in the global banner } VoteHandlingStatus::SchedulingVotes => { let dark_mode = ui.ctx().style().visuals.dark_mode; @@ -1653,8 +1641,7 @@ impl DPNSScreen { .iter() .map(|sv| (sv.contested_name.clone(), sv.vote_choice)) .collect(); - let now = Utc::now().timestamp() as u64; - self.bulk_vote_handling_status = VoteHandlingStatus::CastingVotes(now); + self.bulk_vote_handling_status = VoteHandlingStatus::CastingVotes; if !scheduled_list.is_empty() { AppAction::BackendTasks( vec![ @@ -1840,7 +1827,11 @@ impl ScreenLike for DPNSScreen { } fn display_message(&mut self, message: &str, message_type: MessageType) { - // Sync error states + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.refresh_banner.take_and_clear(); + self.vote_banner.take_and_clear(); + } if message.contains("Error casting scheduled vote") { self.scheduled_vote_cast_in_progress = false; if let Ok(mut guard) = self.scheduled_votes.lock() { @@ -1851,9 +1842,6 @@ impl ScreenLike for DPNSScreen { } } } - - // Save into general error_message for top-of-screen - self.message = Some((message.to_string(), message_type, Utc::now())); } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { @@ -1895,11 +1883,13 @@ impl ScreenLike for DPNSScreen { )); } + self.vote_banner.take_and_clear(); self.bulk_vote_handling_status = VoteHandlingStatus::Completed; } // If scheduling succeeded BackendTaskSuccessResult::ScheduledVotes => { if self.bulk_vote_handling_status == VoteHandlingStatus::SchedulingVotes { + self.vote_banner.take_and_clear(); self.bulk_vote_handling_status = VoteHandlingStatus::Completed; } self.bulk_schedule_message = @@ -1917,6 +1907,7 @@ impl ScreenLike for DPNSScreen { } BackendTaskSuccessResult::RefreshedDpnsContests | BackendTaskSuccessResult::RefreshedOwnedDpnsNames => { + self.refresh_banner.take_and_clear(); self.refreshing_status = RefreshingStatus::NotRefreshing; } _ => {} @@ -1924,7 +1915,6 @@ impl ScreenLike for DPNSScreen { } fn ui(&mut self, ctx: &Context) -> AppAction { - self.check_error_expiration(); let has_identity_that_can_register = !self.user_identities.is_empty(); let has_active_contests = { let guard = self.contested_names.lock().unwrap(); @@ -2094,44 +2084,8 @@ impl ScreenLike for DPNSScreen { } } - // Show either refreshing indicator or message, but not both - if let RefreshingStatus::Refreshing(start_time) = self.refreshing_status { - ui.add_space(25.0); // Space above - let now = Utc::now().timestamp() as u64; - let elapsed = now - start_time; - ui.horizontal(|ui| { - ui.add_space(10.0); - let dark_mode = ui.ctx().style().visuals.dark_mode; - ui.label( - RichText::new(format!("Refreshing... Time taken so far: {}", elapsed)) - .color(DashColors::text_primary(dark_mode)), - ); - ui.add(egui::widgets::Spinner::default().color(DashColors::DASH_BLUE)); - }); - ui.add_space(2.0); // Space below - } else if let Some((msg, msg_type, timestamp)) = self.message.clone() { - ui.add_space(25.0); // Same space as refreshing indicator - let dark_mode = ui.ctx().style().visuals.dark_mode; - let color = match msg_type { - MessageType::Error => Color32::DARK_RED, - MessageType::Warning => DashColors::warning_color(dark_mode), - MessageType::Info => DashColors::text_primary(dark_mode), - MessageType::Success => Color32::DARK_GREEN, - }; - ui.horizontal(|ui| { - ui.add_space(10.0); - - // Calculate remaining seconds - let now = Utc::now(); - let elapsed = now.signed_duration_since(timestamp); - let remaining = (10 - elapsed.num_seconds()).max(0); - - // Add the message with auto-dismiss countdown - let full_msg = format!("{} ({}s)", msg, remaining); - ui.label(egui::RichText::new(full_msg).color(color)); - }); - ui.add_space(2.0); // Same space below as refreshing indicator - } + // Refreshing indicator is shown via the global banner + // (no inline elapsed rendering needed) inner_action }); @@ -2141,19 +2095,32 @@ impl ScreenLike for DPNSScreen { AppAction::BackendTask(BackendTask::ContestedResourceTask( ContestedResourceTask::QueryDPNSContests, )) => { - self.refreshing_status = - RefreshingStatus::Refreshing(Utc::now().timestamp() as u64); - self.message = None; // Clear any existing message + self.refresh_banner.take_and_clear(); + let handle = MessageBanner::set_global( + ctx, + "Refreshing contested names...", + MessageType::Info, + ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); + self.refreshing_status = RefreshingStatus::Refreshing; } // If refreshing owned names, set self.refreshing = true AppAction::BackendTask(BackendTask::IdentityTask( IdentityTask::RefreshLoadedIdentitiesOwnedDPNSNames, )) => { - self.refreshing_status = - RefreshingStatus::Refreshing(Utc::now().timestamp() as u64); - self.message = None; // Clear any existing message + self.refresh_banner.take_and_clear(); + let handle = MessageBanner::set_global( + ctx, + "Refreshing contested names...", + MessageType::Info, + ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); + self.refreshing_status = RefreshingStatus::Refreshing; } AppAction::SetMainScreen(_) => { + self.refresh_banner.take_and_clear(); self.refreshing_status = RefreshingStatus::NotRefreshing; } _ => {} diff --git a/src/ui/identities/add_existing_identity_screen.rs b/src/ui/identities/add_existing_identity_screen.rs index 88b651007..102dbe8df 100644 --- a/src/ui/identities/add_existing_identity_screen.rs +++ b/src/ui/identities/add_existing_identity_screen.rs @@ -11,20 +11,19 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::theme::DashColors; use crate::ui::{MessageType, ScreenLike}; use bip39::rand::{prelude::IteratorRandom, thread_rng}; use dash_sdk::dashcore_rpc::dashcore::Network; -use dash_sdk::dpp::identity::TimestampMillis; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::Identifier; -use eframe::egui::{Context, Frame, Margin}; +use eframe::egui::Context; use egui::{Color32, ComboBox, RichText, Ui}; use serde::Deserialize; use std::fs; use std::sync::atomic::Ordering; use std::sync::{Arc, RwLock}; -use std::time::{SystemTime, UNIX_EPOCH}; #[derive(Debug, Clone, Deserialize)] struct MasternodeInfo { @@ -86,8 +85,8 @@ enum WalletIdentitySearchMode { #[derive(PartialEq)] pub enum AddIdentityStatus { NotStarted, - WaitingForResult(TimestampMillis), - ErrorMessage(String), + WaitingForResult, + Error, Complete, } @@ -104,23 +103,23 @@ pub struct AddExistingIdentityScreen { selected_wallet: Option>>, identity_associated_with_wallet: bool, wallet_unlock_popup: WalletUnlockPopup, - error_message: Option, + wallet_open_attempted: bool, pub identity_index_input: String, pub app_context: Arc, show_pop_up_info: Option, mode: LoadIdentityMode, - backend_message: Option, wallet_search_mode: WalletIdentitySearchMode, success_message: Option, dpns_name_input: String, /// Whether to show advanced options show_advanced_options: bool, + refresh_banner: Option, } impl AddExistingIdentityScreen { pub fn new(app_context: &Arc) -> Self { let selected_wallet = app_context.wallets.read().unwrap().values().next().cloned(); - let (testnet_loaded_nodes, error_message) = if app_context.network == Network::Testnet { + let (testnet_loaded_nodes, init_error) = if app_context.network == Network::Testnet { match load_testnet_nodes_from_yml(".testnet_nodes.yml") { Ok(nodes) => (nodes, None), Err(e) => (None, Some(e)), @@ -128,6 +127,9 @@ impl AddExistingIdentityScreen { } else { (None, None) }; + if let Some(err) = init_error { + MessageBanner::set_global(app_context.egui_ctx(), &err, MessageType::Error); + } Self { identity_id_input: String::new(), identity_type: IdentityType::User, @@ -141,16 +143,16 @@ impl AddExistingIdentityScreen { selected_wallet, identity_associated_with_wallet: true, wallet_unlock_popup: WalletUnlockPopup::new(), - error_message, + wallet_open_attempted: false, identity_index_input: String::new(), app_context: app_context.clone(), show_pop_up_info: None, mode: LoadIdentityMode::IdentityId, - backend_message: None, wallet_search_mode: WalletIdentitySearchMode::SpecificIndex, success_message: None, dpns_name_input: String::new(), show_advanced_options: false, + refresh_banner: None, } } @@ -248,6 +250,7 @@ impl AddExistingIdentityScreen { .clicked() { self.selected_wallet = None; + self.wallet_open_attempted = false; } for (alias, wallet) in &wallets_snapshot { @@ -258,6 +261,7 @@ impl AddExistingIdentityScreen { if ui.selectable_label(is_selected, alias).clicked() { self.selected_wallet = Some(wallet.clone()); + self.wallet_open_attempted = false; } } }); @@ -270,8 +274,11 @@ impl AddExistingIdentityScreen { if wallet_still_loaded { // Try to open wallet without password if it doesn't use one - if let Err(e) = try_open_wallet_no_password(selected_wallet) { - self.error_message = Some(e); + if !self.wallet_open_attempted { + if let Err(e) = try_open_wallet_no_password(selected_wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + } + self.wallet_open_attempted = true; } if wallet_needs_unlock(selected_wallet) { @@ -286,6 +293,7 @@ impl AddExistingIdentityScreen { } } else { self.selected_wallet = None; + self.wallet_open_attempted = false; ui.colored_label( Color32::RED, "Selected wallet is no longer loaded. We'll search unlocked wallets instead.", @@ -465,11 +473,14 @@ impl AddExistingIdentityScreen { .corner_radius(3.0); if ui.add_enabled(is_valid_id, button).clicked() { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.add_identity_status = AddIdentityStatus::WaitingForResult(now); + self.add_identity_status = AddIdentityStatus::WaitingForResult; + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Loading identity...", + MessageType::Info, + ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); action = self.load_identity_clicked(); } @@ -530,6 +541,7 @@ impl AddExistingIdentityScreen { { // Update the selected wallet self.selected_wallet = Some(wallet.clone()); + self.wallet_open_attempted = false; } } }); @@ -576,8 +588,11 @@ impl AddExistingIdentityScreen { let wallet = self.selected_wallet.as_ref().unwrap(); // Try to open wallet without password if it doesn't use one - if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + if !self.wallet_open_attempted { + if let Err(e) = try_open_wallet_no_password(wallet) { + MessageBanner::set_global(self.app_context.egui_ctx(), &e, MessageType::Error); + } + self.wallet_open_attempted = true; } if wallet_needs_unlock(wallet) { @@ -615,8 +630,6 @@ impl AddExistingIdentityScreen { }); if wallet_mode_changed { self.add_identity_status = AddIdentityStatus::NotStarted; - self.error_message = None; - self.backend_message = None; self.success_message = None; } ui.add_space(6.0); @@ -667,13 +680,15 @@ impl AddExistingIdentityScreen { .corner_radius(3.0); if ui.add(button).clicked() { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.add_identity_status = AddIdentityStatus::WaitingForResult(now); - self.backend_message = None; + self.add_identity_status = AddIdentityStatus::WaitingForResult; self.success_message = None; + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Loading identity...", + MessageType::Info, + ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); // Parse identity index input if let Ok(identity_index) = self.identity_index_input.trim().parse::() { @@ -690,8 +705,12 @@ impl AddExistingIdentityScreen { )); } else { // Handle invalid index input - self.add_identity_status = - AddIdentityStatus::ErrorMessage("Invalid identity index".to_string()); + self.add_identity_status = AddIdentityStatus::NotStarted; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Invalid identity index", + MessageType::Error, + ); } } action @@ -824,13 +843,15 @@ impl AddExistingIdentityScreen { .corner_radius(3.0); if ui.add_enabled(is_valid, button).clicked() { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.add_identity_status = AddIdentityStatus::WaitingForResult(now); - self.backend_message = None; + self.add_identity_status = AddIdentityStatus::WaitingForResult; self.success_message = None; + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Loading identity...", + MessageType::Info, + ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); // Get the selected wallet seed hash for key derivation let selected_wallet_seed_hash = if self.identity_associated_with_wallet { @@ -944,10 +965,8 @@ impl AddExistingIdentityScreen { self.keys_input = vec![String::new(), String::new(), String::new()]; self.identity_index_input.clear(); self.dpns_name_input.clear(); - self.error_message = None; self.show_pop_up_info = None; self.add_identity_status = AddIdentityStatus::NotStarted; - self.backend_message = None; self.success_message = None; return AppAction::None; } @@ -958,21 +977,26 @@ impl AddExistingIdentityScreen { impl ScreenLike for AddExistingIdentityScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { + // Error/success display is handled by the global MessageBanner. + // Side-effects only: update status and progress tracking. match message_type { MessageType::Error => { - self.add_identity_status = AddIdentityStatus::ErrorMessage(message.to_string()); + self.refresh_banner.take_and_clear(); + self.add_identity_status = AddIdentityStatus::Error; } MessageType::Success => { // Check if this is a final success message or a progress update if message.starts_with("Successfully loaded") || message.starts_with("Finished loading") { + self.refresh_banner.take_and_clear(); self.success_message = Some(message.to_string()); self.add_identity_status = AddIdentityStatus::Complete; - self.backend_message = None; } else { - // This is a progress update - self.backend_message = Some(message.to_string()); + // This is a progress update - update the banner text + if let Some(ref handle) = self.refresh_banner { + handle.set_message(message); + } } } _ => {} @@ -982,19 +1006,21 @@ impl ScreenLike for AddExistingIdentityScreen { fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { match backend_task_success_result { BackendTaskSuccessResult::LoadedIdentity(_) => { + self.refresh_banner.take_and_clear(); self.success_message = Some("Successfully loaded identity.".to_string()); self.add_identity_status = AddIdentityStatus::Complete; - self.backend_message = None; } BackendTaskSuccessResult::Message(msg) => { // Check if this is a final success message or a progress update if msg.starts_with("Successfully loaded") || msg.starts_with("Finished loading") { + self.refresh_banner.take_and_clear(); self.success_message = Some(msg); self.add_identity_status = AddIdentityStatus::Complete; - self.backend_message = None; } else { - // This is a progress update - self.backend_message = Some(msg); + // This is a progress update - update the banner text + if let Some(ref handle) = self.refresh_banner { + handle.set_message(&msg); + } } } _ => {} @@ -1025,28 +1051,7 @@ impl ScreenLike for AddExistingIdentityScreen { action |= island_central_panel(ctx, |ui| { let mut inner_action = AppAction::None; - // Display error message at the top, outside of scroll area - if let Some(error_message) = self.error_message.clone() { - let error_color = DashColors::ERROR; - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new(format!("Error: {}", error_message)) - .color(error_color), - ); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.error_message = None; - } - }); - }); - ui.add_space(10.0); - } + // Error display is handled by the global MessageBanner egui::ScrollArea::vertical() .auto_shrink([false; 2]) @@ -1092,8 +1097,6 @@ impl ScreenLike for AddExistingIdentityScreen { if mode_changed { self.add_identity_status = AddIdentityStatus::NotStarted; - self.error_message = None; - self.backend_message = None; self.success_message = None; } @@ -1113,70 +1116,7 @@ impl ScreenLike for AddExistingIdentityScreen { } } - ui.add_space(10.0); - - match &self.add_identity_status { - AddIdentityStatus::NotStarted => { - // Do nothing - } - AddIdentityStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed_seconds = now - start_time; - - let display_time = if elapsed_seconds < 60 { - format!( - "{} second{}", - elapsed_seconds, - if elapsed_seconds == 1 { "" } else { "s" } - ) - } else { - let minutes = elapsed_seconds / 60; - let seconds = elapsed_seconds % 60; - format!( - "{} minute{} and {} second{}", - minutes, - if minutes == 1 { "" } else { "s" }, - seconds, - if seconds == 1 { "" } else { "s" } - ) - }; - - // Show progress message with time, or generic loading message - if let Some(ref progress_msg) = self.backend_message { - ui.label(format!("{} ({})", progress_msg, display_time)); - } else { - ui.label(format!("Loading... ({})", display_time)); - } - } - AddIdentityStatus::ErrorMessage(msg) => { - let error_color = DashColors::ERROR; - let msg = msg.clone(); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new(format!("Error: {}", msg)) - .color(error_color), - ); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.add_identity_status = - AddIdentityStatus::NotStarted; - } - }); - }); - } - AddIdentityStatus::Complete => { - // handled above - } - } + // Status display is handled by the global MessageBanner }); inner_action diff --git a/src/ui/identities/add_new_identity_screen/by_platform_address.rs b/src/ui/identities/add_new_identity_screen/by_platform_address.rs index 4984d30df..1be8a1735 100644 --- a/src/ui/identities/add_new_identity_screen/by_platform_address.rs +++ b/src/ui/identities/add_new_identity_screen/by_platform_address.rs @@ -250,14 +250,12 @@ impl AddNewIdentityScreen { .corner_radius(3.0); if ui.add_enabled(can_create, button).clicked() { - self.error_message = None; action = self.register_identity_clicked(FundingMethod::UsePlatformAddress); } ui.add_space(20.0); - // Only show status messages if there's no error - if self.error_message.is_none() { + { ui.vertical_centered(|ui| match step { WalletFundedScreenStep::WaitingForPlatformAcceptance => { ui.heading("=> Waiting for Platform acknowledgement <="); diff --git a/src/ui/identities/add_new_identity_screen/by_using_unused_asset_lock.rs b/src/ui/identities/add_new_identity_screen/by_using_unused_asset_lock.rs index c79213538..e857cbf2e 100644 --- a/src/ui/identities/add_new_identity_screen/by_using_unused_asset_lock.rs +++ b/src/ui/identities/add_new_identity_screen/by_using_unused_asset_lock.rs @@ -1,5 +1,7 @@ use crate::app::AppAction; use crate::model::fee_estimation::format_credits_as_dash; +use crate::ui::MessageType; +use crate::ui::components::message_banner::MessageBanner; use crate::ui::identities::add_new_identity_screen::{ AddNewIdentityScreen, FundingMethod, WalletFundedScreenStep, }; @@ -59,18 +61,23 @@ impl AddNewIdentityScreen { ui.add_space(6.0); - // Button to select this asset lock stays visible regardless of wrapping - if ui.button("Select").clicked() { - // Update the selected asset lock - self.funding_asset_lock = Some(( - tx.clone(), - proof.clone().expect("Asset lock proof is required"), - address.clone(), - )); - - // Update the step to ready to create identity - let mut step = self.step.write().unwrap(); - *step = WalletFundedScreenStep::ReadyToCreate; + if let Some(asset_lock_proof) = proof { + if ui.button("Select").clicked() { + self.funding_asset_lock = Some(( + tx.clone(), + asset_lock_proof.clone(), + address.clone(), + )); + + let mut step = self.step.write().unwrap(); + *step = WalletFundedScreenStep::ReadyToCreate; + } + } else if ui.button("Select").clicked() { + MessageBanner::set_global( + ui.ctx(), + "Asset lock proof is not yet available — the transaction may not be chain-locked yet. Please try again later.", + MessageType::Warning, + ); } }); }); @@ -128,14 +135,12 @@ impl AddNewIdentityScreen { ui.add_space(10.0); if ui.button("Create Identity").clicked() { - self.error_message = None; action |= self.register_identity_clicked(FundingMethod::UseUnusedAssetLock); } ui.add_space(20.0); - // Only show status messages if there's no error - if self.error_message.is_none() { + { ui.vertical_centered(|ui| match step { WalletFundedScreenStep::WaitingForPlatformAcceptance => { ui.heading("=> Waiting for Platform acknowledgement <="); diff --git a/src/ui/identities/add_new_identity_screen/by_using_unused_balance.rs b/src/ui/identities/add_new_identity_screen/by_using_unused_balance.rs index abdbba791..45abb21d9 100644 --- a/src/ui/identities/add_new_identity_screen/by_using_unused_balance.rs +++ b/src/ui/identities/add_new_identity_screen/by_using_unused_balance.rs @@ -88,14 +88,12 @@ impl AddNewIdentityScreen { .frame(true) .corner_radius(3.0); if ui.add(button).clicked() { - self.error_message = None; action = self.register_identity_clicked(FundingMethod::UseWalletBalance); } ui.add_space(20.0); - // Only show status messages if there's no error - if self.error_message.is_none() { + { ui.vertical_centered(|ui| match step { WalletFundedScreenStep::WaitingForAssetLock => { ui.heading( diff --git a/src/ui/identities/add_new_identity_screen/by_wallet_qr_code.rs b/src/ui/identities/add_new_identity_screen/by_wallet_qr_code.rs index 5c8b06a9a..d6b593760 100644 --- a/src/ui/identities/add_new_identity_screen/by_wallet_qr_code.rs +++ b/src/ui/identities/add_new_identity_screen/by_wallet_qr_code.rs @@ -3,6 +3,8 @@ use crate::backend_task::BackendTask; use crate::backend_task::identity::{ IdentityRegistrationInfo, IdentityTask, RegisterIdentityFundingMethod, }; +use crate::ui::MessageType; +use crate::ui::components::MessageBanner; use crate::ui::identities::add_new_identity_screen::{ AddNewIdentityScreen, WalletFundedScreenStep, }; @@ -165,7 +167,7 @@ impl AddNewIdentityScreen { egui::Layout::top_down(egui::Align::Min).with_cross_align(egui::Align::Center), |ui| { if let Err(e) = self.render_qr_code(ui, amount_dash) { - self.error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } ui.add_space(20.0); @@ -199,8 +201,7 @@ impl AddNewIdentityScreen { } } - // Only show status messages if there's no error - if self.error_message.is_none() { + { match step { WalletFundedScreenStep::WaitingOnFunds => { ui.heading("=> Waiting for funds. <="); diff --git a/src/ui/identities/add_new_identity_screen/mod.rs b/src/ui/identities/add_new_identity_screen/mod.rs index 1efc1c4b2..99badcbb5 100644 --- a/src/ui/identities/add_new_identity_screen/mod.rs +++ b/src/ui/identities/add_new_identity_screen/mod.rs @@ -85,10 +85,8 @@ pub struct AddNewIdentityScreen { alias_input: String, copied_to_clipboard: Option>, identity_keys: IdentityKeys, - error_message: Option, - /// Tracks the last error pushed to the global banner to avoid re-sending each frame. - last_global_error: Option, wallet_unlock_popup: WalletUnlockPopup, + wallet_open_attempted: bool, show_pop_up_info: Option, in_key_selection_advanced_mode: bool, pub app_context: Arc, @@ -153,9 +151,8 @@ impl AddNewIdentityScreen { master_private_key_type: KeyType::ECDSA_HASH160, keys_input: vec![], }, - error_message: None, - last_global_error: None, wallet_unlock_popup: WalletUnlockPopup::new(), + wallet_open_attempted: false, show_pop_up_info: None, in_key_selection_advanced_mode: false, app_context: app_context.clone(), @@ -398,6 +395,7 @@ impl AddNewIdentityScreen { let is_open = wallet.read().expect("wallet lock poisoned").is_open(); self.selected_wallet = Some(wallet); + self.wallet_open_attempted = false; self.identity_id_number = self.next_identity_id(); if is_open { @@ -865,12 +863,20 @@ impl AddNewIdentityScreen { // Get selected Platform address and amount from the input fields let Some((platform_addr, amount)) = self.selected_platform_address_for_funding else { - self.error_message = Some("Please select a Platform address".to_string()); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Please select a Platform address", + MessageType::Error, + ); return AppAction::None; }; if amount == 0 { - self.error_message = Some("Amount must be greater than 0".to_string()); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Amount must be greater than 0", + MessageType::Error, + ); return AppAction::None; } @@ -1018,7 +1024,7 @@ impl AddNewIdentityScreen { impl ScreenLike for AddNewIdentityScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { - if message_type == MessageType::Error { + if matches!(message_type, MessageType::Error | MessageType::Warning) { // Reset step so we stop showing "Waiting for Platform acknowledgement". // The error itself is displayed by the global MessageBanner. let mut step = self.step.write().unwrap(); @@ -1103,18 +1109,6 @@ impl ScreenLike for AddNewIdentityScreen { action |= island_central_panel(ctx, |ui| { let mut inner_action = AppAction::None; - // Display local validation errors via the global MessageBanner. - // Only push when the message changes to avoid resetting the banner each frame - // (e.g. try_open_wallet_no_password can re-set error_message every render pass). - if self.error_message != self.last_global_error { - if let Some(error_message) = self.error_message.as_ref() { - MessageBanner::set_global(ui.ctx(), error_message, MessageType::Error); - } else if let Some(old) = self.last_global_error.as_ref() { - MessageBanner::clear_global_message(ui.ctx(), old); - } - self.last_global_error = self.error_message.clone(); - } - ScrollArea::vertical().show(ui, |ui| { let step = {*self.step.read().unwrap()}; if step == WalletFundedScreenStep::Success { @@ -1147,8 +1141,11 @@ impl ScreenLike for AddNewIdentityScreen { let wallet = self.selected_wallet.as_ref().unwrap(); // Try to open wallet without password if it doesn't use one - if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + if !self.wallet_open_attempted { + if let Err(e) = try_open_wallet_no_password(wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + } + self.wallet_open_attempted = true; } // If wallet needs password unlock diff --git a/src/ui/identities/identities_screen.rs b/src/ui/identities/identities_screen.rs index fa4501aa3..f43544cfc 100644 --- a/src/ui/identities/identities_screen.rs +++ b/src/ui/identities/identities_screen.rs @@ -11,7 +11,7 @@ use crate::model::wallet::WalletSeedHash; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; -use crate::ui::components::{BannerHandle, MessageBanner}; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; use crate::ui::identities::register_dpns_name_screen::{ @@ -942,7 +942,7 @@ impl IdentitiesScreen { ); MessageBanner::set_global( self.app_context.egui_ctx(), - &format!("Failed to remove identity: {}", e), + format!("Failed to remove identity: {}", e), MessageType::Error, ); } @@ -1109,10 +1109,8 @@ impl ScreenLike for IdentitiesScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. - if matches!(message_type, MessageType::Error | MessageType::Warning) - && let Some(handle) = self.refresh_banner.take() - { - handle.clear(); + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.refresh_banner.take_and_clear(); } } @@ -1125,9 +1123,7 @@ impl ScreenLike for IdentitiesScreen { { self.pending_refresh_count = self.pending_refresh_count.saturating_sub(1); if self.pending_refresh_count == 0 { - if let Some(handle) = self.refresh_banner.take() { - handle.clear(); - } + self.refresh_banner.take_and_clear(); let message = if self.total_refresh_count == 1 { "Successfully refreshed identity".to_string() } else { @@ -1229,15 +1225,13 @@ impl ScreenLike for IdentitiesScreen { ) }) => { - if let Some(handle) = self.refresh_banner.take() { - handle.clear(); - } self.pending_refresh_count = tasks.len(); self.total_refresh_count = tasks.len(); - let handle = - MessageBanner::set_global(ctx, "Refreshing identities...", MessageType::Info); - handle.with_elapsed(); - self.refresh_banner = Some(handle); + self.refresh_banner.replace_with_elapsed( + ctx, + "Refreshing identities...", + MessageType::Info, + ); } _ => {} } diff --git a/src/ui/identities/keys/add_key_screen.rs b/src/ui/identities/keys/add_key_screen.rs index 970a9a3cd..8cd6221e2 100644 --- a/src/ui/identities/keys/add_key_screen.rs +++ b/src/ui/identities/keys/add_key_screen.rs @@ -12,6 +12,7 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt, ResultBannerExt}; use crate::ui::identities::get_selected_wallet; use crate::ui::theme::DashColors; use crate::ui::{MessageType, ScreenLike}; @@ -24,18 +25,16 @@ use dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::prelude::Identifier; -use dash_sdk::dpp::prelude::TimestampMillis; use eframe::egui::{self, Context, Frame, Margin}; use egui::{Color32, RichText, Ui}; use std::collections::HashSet; use std::sync::{Arc, RwLock}; -use std::time::{SystemTime, UNIX_EPOCH}; #[derive(PartialEq)] pub enum AddKeyStatus { NotStarted, - WaitingForResult(TimestampMillis), - ErrorMessage(String), + WaitingForResult, + Error, Complete, } @@ -49,12 +48,13 @@ pub struct AddKeyScreen { add_key_status: AddKeyStatus, selected_wallet: Option>>, wallet_unlock_popup: WalletUnlockPopup, - error_message: Option, + wallet_open_attempted: bool, contract_id_input: String, document_type_input: String, enable_contract_bounds: bool, // Fee result from completed operation completed_fee_result: Option, + refresh_banner: Option, } impl AddKeyScreen { @@ -66,9 +66,9 @@ impl AddKeyScreen { KeyType::all_key_types().into(), false, ); - let mut error_message = None; - let selected_wallet = - get_selected_wallet(&identity, None, selected_key, &mut error_message); + let selected_wallet = get_selected_wallet(&identity, None, selected_key) + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None); Self { identity, @@ -80,11 +80,12 @@ impl AddKeyScreen { add_key_status: AddKeyStatus::NotStarted, selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), - error_message, + wallet_open_attempted: false, contract_id_input: String::new(), document_type_input: String::new(), enable_contract_bounds: false, completed_fee_result: None, + refresh_banner: None, } } @@ -101,9 +102,9 @@ impl AddKeyScreen { KeyType::all_key_types().into(), false, ); - let mut error_message = None; - let selected_wallet = - get_selected_wallet(&identity, None, selected_key, &mut error_message); + let selected_wallet = get_selected_wallet(&identity, None, selected_key) + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None); let dashpay_contract_id = app_context .dashpay_contract @@ -120,11 +121,12 @@ impl AddKeyScreen { add_key_status: AddKeyStatus::NotStarted, selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), - error_message, + wallet_open_attempted: false, contract_id_input: dashpay_contract_id, document_type_input: String::new(), enable_contract_bounds: true, completed_fee_result: None, + refresh_banner: None, } } @@ -141,9 +143,9 @@ impl AddKeyScreen { KeyType::all_key_types().into(), false, ); - let mut error_message = None; - let selected_wallet = - get_selected_wallet(&identity, None, selected_key, &mut error_message); + let selected_wallet = get_selected_wallet(&identity, None, selected_key) + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None); let dashpay_contract_id = app_context .dashpay_contract @@ -160,11 +162,12 @@ impl AddKeyScreen { add_key_status: AddKeyStatus::NotStarted, selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), - error_message, + wallet_open_attempted: false, contract_id_input: dashpay_contract_id, document_type_input: String::new(), enable_contract_bounds: true, completed_fee_result: None, + refresh_banner: None, } } @@ -179,8 +182,12 @@ impl AddKeyScreen { self.app_context.network, ); if let Err(err) = public_key_data_result { - self.add_key_status = - AddKeyStatus::ErrorMessage(format!("Issue verifying private key: {}", err)); + self.add_key_status = AddKeyStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + format!("Issue verifying private key: {}", err), + MessageType::Error, + ); } else { // Handle contract bounds if enabled let contract_bounds = if self.enable_contract_bounds @@ -198,10 +205,12 @@ impl AddKeyScreen { } } Err(e) => { - self.add_key_status = AddKeyStatus::ErrorMessage(format!( - "Invalid contract ID: {}", - e - )); + self.add_key_status = AddKeyStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + format!("Invalid contract ID: {}", e), + MessageType::Error, + ); return app_action; } } @@ -224,10 +233,12 @@ impl AddKeyScreen { let validation_result = new_key .validate_private_key_bytes(&private_key_bytes, self.app_context.network); if let Err(err) = validation_result { - self.add_key_status = AddKeyStatus::ErrorMessage(format!( - "Issue verifying private key: {}", - err - )); + self.add_key_status = AddKeyStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + format!("Issue verifying private key: {}", err), + MessageType::Error, + ); } else if validation_result.unwrap() { let new_qualified_key = QualifiedIdentityPublicKey { identity_public_key: new_key.into(), @@ -241,19 +252,30 @@ impl AddKeyScreen { ), )); } else { - self.add_key_status = AddKeyStatus::ErrorMessage( - "Private key does not match the public key.".to_string(), + self.add_key_status = AddKeyStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Private key does not match the public key.", + MessageType::Error, ); } } } Ok(_) => { - self.add_key_status = - AddKeyStatus::ErrorMessage("Private key not 32 bytes".to_string()); + self.add_key_status = AddKeyStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Private key not 32 bytes", + MessageType::Error, + ); } Err(_) => { - self.add_key_status = - AddKeyStatus::ErrorMessage("Invalid hex string for private key.".to_string()); + self.add_key_status = AddKeyStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Invalid hex string for private key.", + MessageType::Error, + ); } } app_action @@ -270,8 +292,12 @@ impl AddKeyScreen { { self.private_key_input = hex::encode(private_key_bytes); } else { - self.add_key_status = - AddKeyStatus::ErrorMessage("Failed to generate a random private key.".to_string()); + self.add_key_status = AddKeyStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Failed to generate a random private key.", + MessageType::Error, + ); } } @@ -313,10 +339,12 @@ impl AddKeyScreen { impl ScreenLike for AddKeyScreen { fn refresh(&mut self) { - if let Some(refreshed_identity) = self + let identities = self .app_context .load_local_user_identities() - .expect("Expected to load local identities") + .or_show_error(self.app_context.egui_ctx()) + .unwrap_or_default(); + if let Some(refreshed_identity) = identities .iter() .find(|identity| identity.identity.id() == self.identity.identity.id()) { @@ -324,15 +352,18 @@ impl ScreenLike for AddKeyScreen { } } - fn display_message(&mut self, message: &str, message_type: MessageType) { - if let MessageType::Error = message_type { - self.add_key_status = AddKeyStatus::ErrorMessage(message.to_string()); + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Error/success display is handled by the global MessageBanner. + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.refresh_banner.take_and_clear(); + self.add_key_status = AddKeyStatus::Error; } } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { match backend_task_success_result { BackendTaskSuccessResult::AddedKeyToIdentity(fee_result) => { + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.add_key_status = AddKeyStatus::Complete; } @@ -372,16 +403,14 @@ impl ScreenLike for AddKeyScreen { ui.heading("Add New Key"); ui.add_space(10.0); - if self.add_key_status == AddKeyStatus::Complete { - inner_action |= self.show_success(ui); - return inner_action; - } - if self.selected_wallet.is_some() && let Some(wallet) = &self.selected_wallet { - if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + if !self.wallet_open_attempted { + if let Err(e) = try_open_wallet_no_password(wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + } + self.wallet_open_attempted = true; } if wallet_needs_unlock(wallet) { ui.add_space(10.0); @@ -641,71 +670,17 @@ impl ScreenLike for AddKeyScreen { .frame(true) .corner_radius(3.0); if ui.add(button).clicked() { - // Set the status to waiting and capture the current time - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.add_key_status = AddKeyStatus::WaitingForResult(now); - inner_action |= self.validate_and_add_key(); - } - ui.add_space(10.0); - - match &self.add_key_status { - AddKeyStatus::NotStarted => { - // Do nothing - } - AddKeyStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed_seconds = now - start_time; - - let display_time = if elapsed_seconds < 60 { - format!( - "{} second{}", - elapsed_seconds, - if elapsed_seconds == 1 { "" } else { "s" } - ) - } else { - let minutes = elapsed_seconds / 60; - let seconds = elapsed_seconds % 60; - format!( - "{} minute{} and {} second{}", - minutes, - if minutes == 1 { "" } else { "s" }, - seconds, - if seconds == 1 { "" } else { "s" } - ) - }; - - ui.label(format!("Adding key... Time taken so far: {}", display_time)); - } - AddKeyStatus::ErrorMessage(msg) => { - let error_color = DashColors::ERROR; - let msg = msg.clone(); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new(format!("Error: {}", msg)).color(error_color), - ); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.add_key_status = AddKeyStatus::NotStarted; - } - }); - }); - } - AddKeyStatus::Complete => { - // handled above + let validation_action = self.validate_and_add_key(); + if matches!(&validation_action, AppAction::BackendTask(_)) { + self.add_key_status = AddKeyStatus::WaitingForResult; + let handle = + MessageBanner::set_global(ui.ctx(), "Adding key...", MessageType::Info); + handle.with_elapsed(); + self.refresh_banner = Some(handle); } + inner_action |= validation_action; } + // Status display is handled by the global MessageBanner inner_action }); diff --git a/src/ui/identities/keys/key_info_screen.rs b/src/ui/identities/keys/key_info_screen.rs index 1917ab876..88b41d67f 100644 --- a/src/ui/identities/keys/key_info_screen.rs +++ b/src/ui/identities/keys/key_info_screen.rs @@ -5,7 +5,7 @@ use crate::model::qualified_identity::encrypted_key_storage::{ PrivateKeyData, WalletDerivationPath, }; use crate::model::wallet::Wallet; -use crate::ui::ScreenLike; +use crate::ui::components::MessageBanner; use crate::ui::components::info_popup::InfoPopup; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; @@ -14,6 +14,7 @@ use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; use crate::ui::theme::DashColors; +use crate::ui::{MessageType, ScreenLike}; use base64::Engine; use base64::engine::general_purpose::STANDARD; use dash_sdk::dashcore_rpc::dashcore::PrivateKey as RPCPrivateKey; @@ -30,7 +31,7 @@ use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBound use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::IdentityPublicKey; use eframe::egui::{self, Context}; -use egui::{Color32, Frame, Margin, RichText, ScrollArea}; +use egui::{Color32, RichText, ScrollArea}; use std::sync::{Arc, RwLock}; pub struct KeyInfoScreen { @@ -40,12 +41,11 @@ pub struct KeyInfoScreen { pub decrypted_private_key: Option, pub app_context: Arc, private_key_input: String, - error_message: Option, selected_wallet: Option>>, wallet_unlock_popup: WalletUnlockPopup, + wallet_open_attempted: bool, message_input: String, signed_message: Option, - sign_error_message: Option, view_wallet_unlock: bool, wallet_open: bool, view_private_key_even_if_encrypted_or_in_wallet: bool, @@ -504,35 +504,17 @@ impl ScreenLike for KeyInfoScreen { if ui.button("Add Private Key").clicked() { self.validate_and_store_private_key(); } - - // Display error message if validation fails - if let Some(error_message) = self.error_message.clone() { - let error_color = DashColors::ERROR; - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new(format!("Error: {}", error_message)) - .color(error_color), - ); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.error_message = None; - } - }); - }); - } + // Error display is handled by the global MessageBanner } if self.view_wallet_unlock && let Some(wallet) = &self.selected_wallet { - if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + if !self.wallet_open_attempted { + if let Err(e) = try_open_wallet_no_password(wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + } + self.wallet_open_attempted = true; } if wallet_needs_unlock(wallet) { ui.add_space(10.0); @@ -611,12 +593,11 @@ impl KeyInfoScreen { decrypted_private_key: None, app_context: app_context.clone(), private_key_input: String::new(), - error_message: None, selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), + wallet_open_attempted: false, message_input: "".to_string(), signed_message: None, - sign_error_message: None, view_wallet_unlock: false, wallet_open: false, view_private_key_even_if_encrypted_or_in_wallet: false, @@ -632,14 +613,21 @@ impl KeyInfoScreen { private_key_bytes_vec.try_into().unwrap() } Ok(_) => { - self.error_message = Some("Private key not 32 bytes".to_string()); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Private key not 32 bytes", + MessageType::Error, + ); return; } Err(_) => match PrivateKey::from_wif(&self.private_key_input) { Ok(key) => key.inner.secret_bytes(), Err(_) => { - self.error_message = - Some("Invalid hex string or WIF for private key.".to_string()); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Invalid hex string or WIF for private key.", + MessageType::Error, + ); return; } }, @@ -649,7 +637,11 @@ impl KeyInfoScreen { .key .validate_private_key_bytes(&private_key_bytes, self.app_context.network); if let Err(err) = validation_result { - self.error_message = Some(format!("Issue verifying private key {}", err)); + MessageBanner::set_global( + self.app_context.egui_ctx(), + format!("Issue verifying private key {}", err), + MessageType::Error, + ); } else if validation_result.unwrap() { // If valid, store the private key in the context and reset the input field self.private_key_data = Some((PrivateKeyData::Clear(private_key_bytes), None)); @@ -657,19 +649,22 @@ impl KeyInfoScreen { (self.key.purpose().into(), self.key.id()), (self.key.clone().into(), private_key_bytes), ); - match self + if let Err(e) = self .app_context .update_local_qualified_identity(&self.identity) { - Ok(_) => { - self.error_message = None; - } - Err(e) => { - self.error_message = Some(format!("Issue saving: {}", e)); - } + MessageBanner::set_global( + self.app_context.egui_ctx(), + format!("Issue saving: {}", e), + MessageType::Error, + ); } } else { - self.error_message = Some("Private key does not match the public key.".to_string()); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Private key does not match the public key.", + MessageType::Error, + ); } } @@ -706,25 +701,7 @@ impl KeyInfoScreen { self.sign_message(); } - if let Some(error_message) = self.sign_error_message.clone() { - let error_color = DashColors::ERROR; - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new(format!("Error: {}", error_message)).color(error_color), - ); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.sign_error_message = None; - } - }); - }); - } + // Sign error display is handled by the global MessageBanner if let Some(signed_message) = &self.signed_message { ui.add_space(10.0); @@ -751,7 +728,11 @@ impl KeyInfoScreen { (_, Some(private_key)) => private_key.inner.secret_bytes(), // Other cases may not have the private key directly _ => { - self.sign_error_message = Some("Private key is not available.".to_string()); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Private key is not available.", + MessageType::Error, + ); return; } }; @@ -777,14 +758,21 @@ impl KeyInfoScreen { let signature_base64 = STANDARD.encode(serialized_signature); self.signed_message = Some(signature_base64); - self.sign_error_message = None; } _ => { - self.sign_error_message = Some("Unsupported key type for signing.".to_string()); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Unsupported key type for signing.", + MessageType::Error, + ); } } } else { - self.sign_error_message = Some("Private key is not available.".to_string()); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Private key is not available.", + MessageType::Error, + ); } } @@ -811,16 +799,15 @@ impl KeyInfoScreen { .private_keys .private_keys .remove(&(self.key.purpose().into(), self.key.id())); - match self + if let Err(e) = self .app_context .update_local_qualified_identity(&self.identity) { - Ok(_) => { - self.error_message = None; - } - Err(e) => { - self.error_message = Some(format!("Issue saving: {}", e)); - } + MessageBanner::set_global( + ui.ctx(), + format!("Issue saving: {}", e), + MessageType::Error, + ); } self.show_confirm_remove_private_key = false; } diff --git a/src/ui/identities/mod.rs b/src/ui/identities/mod.rs index eeec7b7b3..d582aa176 100644 --- a/src/ui/identities/mod.rs +++ b/src/ui/identities/mod.rs @@ -48,61 +48,42 @@ pub mod withdraw_screen; /// DPNS contract. When present, DPNS logic is used to find the public key. /// - `selected_key`: An optional reference to a chosen [`IdentityPublicKey`]. /// When `app_context` is not provided, this is required to get the wallet. -/// - `error_message`: A mutable optional string where any error message will -/// be written if the function fails to retrieve a wallet. /// /// # Returns /// -/// Returns `Some(Arc>)` if a matching wallet is found, or `None` -/// otherwise. If an error is encountered, an explanatory message is placed in -/// `error_message`. +/// Returns `Ok(Some(Arc>))` if a matching wallet is found, +/// `Ok(None)` if no wallet is associated with the key, or `Err(String)` if +/// an error is encountered. /// /// # Errors /// /// - If the DPNS document type can't be found or the identity is missing the /// required DPNS signing key (when `app_context` is provided). /// - If no `selected_key` is provided (when `app_context` is `None`). -/// - If the derived wallet derivation path is missing from the -/// [`QualifiedIdentity`]. pub fn get_selected_wallet( qualified_identity: &QualifiedIdentity, - app_context: Option<&AppContext>, // Used for DPNS-based logic (the first scenario). - selected_key: Option<&IdentityPublicKey>, // Used for direct-key logic (the fallback scenario). - error_message: &mut Option, -) -> Option>> { + app_context: Option<&AppContext>, + selected_key: Option<&IdentityPublicKey>, +) -> Result>>, String> { // If `app_context` is provided, use the DPNS-based approach. let public_key = if let Some(context) = app_context { let dpns_contract = &context.dpns_contract; // Attempt to fetch the `preorder` document type from the DPNS contract. - let preorder_document_type = match dpns_contract.document_type_for_name("preorder") { - Ok(doc_type) => doc_type, - Err(e) => { - *error_message = Some(format!("DPNS preorder document type not found: {}", e)); - return None; - } - }; + let preorder_document_type = dpns_contract + .document_type_for_name("preorder") + .map_err(|e| format!("DPNS preorder document type not found: {}", e))?; // Attempt to retrieve the public key from the identity. - match qualified_identity.document_signing_key(&preorder_document_type) { - Some(key) => key, - None => { - *error_message = Some( - "Identity doesn't have an authentication key for signing document transitions" - .to_string(), - ); - return None; - } - } + qualified_identity + .document_signing_key(&preorder_document_type) + .ok_or_else(|| { + "Identity doesn't have an authentication key for signing document transitions" + .to_string() + })? } else { // Fallback: directly use the provided selected key. - match selected_key { - Some(key) => key, - None => { - *error_message = Some("No key provided when getting selected wallet".to_string()); - return None; - } - } + selected_key.ok_or_else(|| "No key provided when getting selected wallet".to_string())? }; // Once we have the public key (either from DPNS or directly), look up @@ -115,11 +96,11 @@ pub fn get_selected_wallet( .get(&key_lookup) { // If found, return the associated wallet (cloned to preserve Arc). - qualified_identity + Ok(qualified_identity .associated_wallets .get(&wallet_derivation_path.wallet_seed_hash) - .cloned() + .cloned()) } else { - None + Ok(None) } } diff --git a/src/ui/identities/register_dpns_name_screen.rs b/src/ui/identities/register_dpns_name_screen.rs index 7ceb12809..b5b25abe8 100644 --- a/src/ui/identities/register_dpns_name_screen.rs +++ b/src/ui/identities/register_dpns_name_screen.rs @@ -12,19 +12,19 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt, ResultBannerExt}; use crate::ui::helpers::{TransactionType, add_key_chooser_with_doc_type}; use crate::ui::theme::DashColors; use crate::ui::{MessageType, ScreenLike}; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; +use dash_sdk::dpp::identity::Purpose; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; -use dash_sdk::dpp::identity::{Purpose, TimestampMillis}; use dash_sdk::platform::{Identifier, IdentityPublicKey}; use eframe::egui::{Context, Frame, Margin}; use egui::{Color32, RichText, Ui}; use std::sync::Arc; use std::sync::RwLock; -use std::time::{SystemTime, UNIX_EPOCH}; use super::get_selected_wallet; @@ -39,8 +39,8 @@ pub enum RegisterDpnsNameSource { #[derive(PartialEq)] pub enum RegisterDpnsNameStatus { NotStarted, - WaitingForResult(TimestampMillis), - ErrorMessage(String), + WaitingForResult, + Error, Complete, } @@ -55,12 +55,13 @@ pub struct RegisterDpnsNameScreen { pub app_context: Arc, selected_wallet: Option>>, wallet_unlock_popup: WalletUnlockPopup, - error_message: Option, + wallet_open_attempted: bool, show_advanced_options: bool, // Fee result from completed operation completed_fee_result: Option, // Source of navigation to this screen pub source: RegisterDpnsNameSource, + refresh_banner: Option, } impl RegisterDpnsNameScreen { @@ -69,9 +70,10 @@ impl RegisterDpnsNameScreen { app_context.load_local_user_identities().unwrap_or_default(); let selected_qualified_identity = qualified_identities.first().cloned(); - let mut error_message: Option = None; let selected_wallet = if let Some(ref identity) = selected_qualified_identity { - get_selected_wallet(identity, Some(app_context), None, &mut error_message) + get_selected_wallet(identity, Some(app_context), None) + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None) } else { None }; @@ -118,10 +120,11 @@ impl RegisterDpnsNameScreen { app_context: app_context.clone(), selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), - error_message, + wallet_open_attempted: false, show_advanced_options: false, completed_fee_result: None, source, + refresh_banner: None, } } @@ -159,8 +162,10 @@ impl RegisterDpnsNameScreen { .cloned(); // Update the selected wallet - self.selected_wallet = - get_selected_wallet(qi, Some(&self.app_context), None, &mut self.error_message); + self.selected_wallet = get_selected_wallet(qi, Some(&self.app_context), None) + .or_show_error(self.app_context.egui_ctx()) + .unwrap_or(None); + self.wallet_open_attempted = false; } else { // If not found, you might want to handle this case // For now, we'll set selected_qualified_identity to None @@ -168,6 +173,7 @@ impl RegisterDpnsNameScreen { self.selected_identity_string = String::new(); self.selected_key = None; self.selected_wallet = None; + self.wallet_open_attempted = false; } } @@ -211,15 +217,14 @@ impl RegisterDpnsNameScreen { .cloned(); // Update wallet - self.selected_wallet = get_selected_wallet( - identity, - Some(&self.app_context), - None, - &mut self.error_message, - ); + self.selected_wallet = get_selected_wallet(identity, Some(&self.app_context), None) + .or_show_error(self.app_context.egui_ctx()) + .unwrap_or(None); + self.wallet_open_attempted = false; } else { self.selected_key = None; self.selected_wallet = None; + self.wallet_open_attempted = false; } } @@ -294,10 +299,11 @@ impl RegisterDpnsNameScreen { } impl ScreenLike for RegisterDpnsNameScreen { - fn display_message(&mut self, message: &str, message_type: MessageType) { - if let MessageType::Error = message_type { - self.register_dpns_name_status = - RegisterDpnsNameStatus::ErrorMessage(message.to_string()); + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.refresh_banner.take_and_clear(); + self.register_dpns_name_status = RegisterDpnsNameStatus::Error; } } @@ -305,6 +311,7 @@ impl ScreenLike for RegisterDpnsNameScreen { if let BackendTaskSuccessResult::RegisteredDpnsName(fee_result) = backend_task_success_result { + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.register_dpns_name_status = RegisterDpnsNameStatus::Complete; } @@ -402,8 +409,11 @@ impl ScreenLike for RegisterDpnsNameScreen { if self.selected_wallet.is_some() && let Some(wallet) = &self.selected_wallet { - if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + if !self.wallet_open_attempted { + if let Err(e) = try_open_wallet_no_password(wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + } + self.wallet_open_attempted = true; } if wallet_needs_unlock(wallet) { ui.add_space(10.0); @@ -542,73 +552,13 @@ impl ScreenLike for RegisterDpnsNameScreen { .on_disabled_hover_text(&hover_text) .clicked() { - // Set the status to waiting and capture the current time - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.register_dpns_name_status = RegisterDpnsNameStatus::WaitingForResult(now); + self.register_dpns_name_status = RegisterDpnsNameStatus::WaitingForResult; + let handle = MessageBanner::set_global(ui.ctx(), "Registering DPNS name...", MessageType::Info); + handle.with_elapsed(); + self.refresh_banner = Some(handle); inner_action = self.register_dpns_name_clicked(); } - ui.add_space(10.0); - - // Handle registration status messages - match &self.register_dpns_name_status { - RegisterDpnsNameStatus::NotStarted => { - // Do nothing - } - RegisterDpnsNameStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed_seconds = now - start_time; - - let display_time = if elapsed_seconds < 60 { - format!( - "{} second{}", - elapsed_seconds, - if elapsed_seconds == 1 { "" } else { "s" } - ) - } else { - let minutes = elapsed_seconds / 60; - let seconds = elapsed_seconds % 60; - format!( - "{} minute{} and {} second{}", - minutes, - if minutes == 1 { "" } else { "s" }, - seconds, - if seconds == 1 { "" } else { "s" } - ) - }; - - ui.label(format!( - "Registering... Time taken so far: {}", - display_time - )); - } - RegisterDpnsNameStatus::ErrorMessage(msg) => { - let error_color = DashColors::ERROR; - let msg = msg.clone(); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new(format!("Error: {}", msg)).color(error_color)); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.register_dpns_name_status = RegisterDpnsNameStatus::NotStarted; - } - }); - }); - } - RegisterDpnsNameStatus::Complete => {} - } - ui.add_space(10.0); ui.separator(); ui.add_space(10.0); diff --git a/src/ui/identities/top_up_identity_screen/by_using_unused_asset_lock.rs b/src/ui/identities/top_up_identity_screen/by_using_unused_asset_lock.rs index f4854c908..dfca0e870 100644 --- a/src/ui/identities/top_up_identity_screen/by_using_unused_asset_lock.rs +++ b/src/ui/identities/top_up_identity_screen/by_using_unused_asset_lock.rs @@ -1,5 +1,7 @@ use crate::app::AppAction; use crate::model::fee_estimation::format_credits_as_dash; +use crate::ui::MessageType; +use crate::ui::components::message_banner::MessageBanner; use crate::ui::identities::add_new_identity_screen::FundingMethod; use crate::ui::identities::top_up_identity_screen::{TopUpIdentityScreen, WalletFundedScreenStep}; use crate::ui::theme::DashColors; @@ -53,18 +55,20 @@ impl TopUpIdentityScreen { tx_id, address, lock_amount, is_locked, selected_text )); - // Button to select this asset lock - if ui.button("Select").clicked() { - // Update the selected asset lock - self.funding_asset_lock = Some(( - tx.clone(), - proof.clone().expect("Asset lock proof is required"), - address.clone(), - )); - - // Update the step to ready to create identity - let mut step = self.step.write().unwrap(); - *step = WalletFundedScreenStep::ReadyToCreate; + if let Some(asset_lock_proof) = proof { + if ui.button("Select").clicked() { + self.funding_asset_lock = + Some((tx.clone(), asset_lock_proof.clone(), address.clone())); + + let mut step = self.step.write().unwrap(); + *step = WalletFundedScreenStep::ReadyToCreate; + } + } else if ui.button("Select").clicked() { + MessageBanner::set_global( + ui.ctx(), + "Asset lock proof is not yet available — the transaction may not be chain-locked yet. Please try again later.", + MessageType::Warning, + ); } }); diff --git a/src/ui/identities/top_up_identity_screen/mod.rs b/src/ui/identities/top_up_identity_screen/mod.rs index cd13a469a..f750a654f 100644 --- a/src/ui/identities/top_up_identity_screen/mod.rs +++ b/src/ui/identities/top_up_identity_screen/mod.rs @@ -55,6 +55,7 @@ pub struct TopUpIdentityScreen { funding_utxo: Option<(OutPoint, TxOut, Address)>, copied_to_clipboard: Option>, wallet_unlock_popup: WalletUnlockPopup, + wallet_open_attempted: bool, show_pop_up_info: Option, pub app_context: Arc, // Platform address fields @@ -80,6 +81,7 @@ impl TopUpIdentityScreen { funding_utxo: None, copied_to_clipboard: None, wallet_unlock_popup: WalletUnlockPopup::new(), + wallet_open_attempted: false, show_pop_up_info: None, app_context: app_context.clone(), selected_platform_address: None, @@ -178,6 +180,7 @@ impl TopUpIdentityScreen { if let Some(wallet) = selected_wallet_update { self.wallet = Some(wallet); + self.wallet_open_attempted = false; self.funding_address = None; self.funding_asset_lock = None; self.funding_utxo = None; @@ -610,8 +613,11 @@ impl ScreenLike for TopUpIdentityScreen { }; if let Some(wallet) = &self.wallet { - if let Err(e) = try_open_wallet_no_password(wallet) { - MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + if !self.wallet_open_attempted { + if let Err(e) = try_open_wallet_no_password(wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + } + self.wallet_open_attempted = true; } if wallet_needs_unlock(wallet) { ui.add_space(10.0); diff --git a/src/ui/identities/transfer_screen.rs b/src/ui/identities/transfer_screen.rs index 4a45c1713..392a70ece 100644 --- a/src/ui/identities/transfer_screen.rs +++ b/src/ui/identities/transfer_screen.rs @@ -13,6 +13,7 @@ use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt, ResultBannerExt}; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dashcore_rpc::dashcore::Address; @@ -23,13 +24,11 @@ use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; -use dash_sdk::dpp::prelude::TimestampMillis; use dash_sdk::platform::{Identifier, IdentityPublicKey}; use eframe::egui::{self, Context, Frame, Margin, Ui}; use egui::{Color32, RichText}; use std::collections::BTreeMap; use std::sync::{Arc, RwLock}; -use std::time::{SystemTime, UNIX_EPOCH}; use super::get_selected_wallet; use super::keys::add_key_screen::AddKeyScreen; @@ -50,8 +49,8 @@ pub enum TransferDestinationType { #[derive(PartialEq)] pub enum TransferCreditsStatus { NotStarted, - WaitingForResult(TimestampMillis), - ErrorMessage(String), + WaitingForResult, + Error, Complete, } @@ -63,26 +62,28 @@ pub struct TransferScreen { amount: Option, amount_input: Option, transfer_credits_status: TransferCreditsStatus, - error_message: Option, max_amount: u64, pub app_context: Arc, confirmation_popup: bool, confirmation_dialog: Option, selected_wallet: Option>>, wallet_unlock_popup: WalletUnlockPopup, + wallet_open_attempted: bool, // Platform address transfer fields destination_type: TransferDestinationType, platform_address_input: String, show_advanced_options: bool, // Fee result from completed operation completed_fee_result: Option, + refresh_banner: Option, } impl TransferScreen { pub fn new(identity: QualifiedIdentity, app_context: &Arc) -> Self { let known_identities = app_context .load_local_qualified_identities() - .expect("Identities not loaded"); + .or_show_error(app_context.egui_ctx()) + .unwrap_or_default(); let max_amount = identity.identity.balance(); let identity_clone = identity.identity.clone(); @@ -92,9 +93,11 @@ impl TransferScreen { KeyType::all_key_types().into(), false, ); - let mut error_message = None; let selected_wallet = - get_selected_wallet(&identity, None, selected_key, &mut error_message); + get_selected_wallet(&identity, None, selected_key).unwrap_or_else(|e| { + MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); + None + }); Self { identity, selected_key: selected_key.cloned(), @@ -103,17 +106,18 @@ impl TransferScreen { amount: Some(Amount::new_dash(0.0)), amount_input: None, transfer_credits_status: TransferCreditsStatus::NotStarted, - error_message: None, max_amount, app_context: app_context.clone(), confirmation_popup: false, confirmation_dialog: None, selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), + wallet_open_attempted: false, destination_type: TransferDestinationType::Identity, platform_address_input: String::new(), show_advanced_options: false, completed_fee_result: None, + refresh_banner: None, } } @@ -146,8 +150,8 @@ impl TransferScreen { // Check if input should be disabled when operation is in progress let enabled = match self.transfer_credits_status { - TransferCreditsStatus::WaitingForResult(_) | TransferCreditsStatus::Complete => false, - TransferCreditsStatus::NotStarted | TransferCreditsStatus::ErrorMessage(_) => { + TransferCreditsStatus::WaitingForResult | TransferCreditsStatus::Complete => false, + TransferCreditsStatus::NotStarted | TransferCreditsStatus::Error => { amount_input.set_max_amount(Some(max_amount_credits)); true } @@ -311,18 +315,39 @@ impl TransferScreen { // Get the amount let credits = self.amount.as_ref().map(|v| v.value()).unwrap_or_default() as u128; if credits == 0 { - self.error_message = Some("Amount must be greater than 0".to_string()); - self.transfer_credits_status = - TransferCreditsStatus::ErrorMessage("Amount must be greater than 0".to_string()); + self.transfer_credits_status = TransferCreditsStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Amount must be greater than 0", + MessageType::Error, + ); + return AppAction::None; + } + + // Validate amount against estimated fees + let estimated_fee = self + .app_context + .fee_estimator() + .estimate_credit_transfer_to_addresses(1); + let max_transferable = + (self.identity.identity.balance() as u128).saturating_sub(estimated_fee as u128); + if credits > max_transferable { + self.set_error_state(format!( + "Amount plus estimated fee exceeds available balance (max transferable: {})", + format_credits_as_dash(max_transferable as u64) + )); return AppAction::None; } // Set waiting state - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.transfer_credits_status = TransferCreditsStatus::WaitingForResult(now); + self.transfer_credits_status = TransferCreditsStatus::WaitingForResult; + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Transferring credits...", + MessageType::Info, + ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); // Build outputs let mut outputs: BTreeMap = BTreeMap::new(); @@ -363,19 +388,38 @@ impl TransferScreen { // Use the amount directly since it's already an Amount struct let credits = self.amount.as_ref().map(|v| v.value()).unwrap_or_default() as u128; if credits == 0 { - self.error_message = Some("Amount must be greater than 0".to_string()); - self.transfer_credits_status = - TransferCreditsStatus::ErrorMessage("Amount must be greater than 0".to_string()); + self.transfer_credits_status = TransferCreditsStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Amount must be greater than 0", + MessageType::Error, + ); + self.confirmation_popup = false; + return AppAction::None; + } + + // Validate amount against estimated fees + let estimated_fee = self.app_context.fee_estimator().estimate_credit_transfer(); + let max_transferable = + (self.identity.identity.balance() as u128).saturating_sub(estimated_fee as u128); + if credits > max_transferable { + self.set_error_state(format!( + "Amount plus estimated fee exceeds available balance (max transferable: {})", + format_credits_as_dash(max_transferable as u64) + )); self.confirmation_popup = false; return AppAction::None; } // Set waiting state and create backend task - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.transfer_credits_status = TransferCreditsStatus::WaitingForResult(now); + self.transfer_credits_status = TransferCreditsStatus::WaitingForResult; + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Transferring credits...", + MessageType::Info, + ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); AppAction::BackendTask(BackendTask::IdentityTask(IdentityTask::Transfer( self.identity.clone(), @@ -407,8 +451,8 @@ impl TransferScreen { /// Set error state with the given message fn set_error_state(&mut self, error: String) { - self.error_message = Some(error.clone()); - self.transfer_credits_status = TransferCreditsStatus::ErrorMessage(error); + self.transfer_credits_status = TransferCreditsStatus::Error; + MessageBanner::set_global(self.app_context.egui_ctx(), &error, MessageType::Error); } fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { @@ -487,10 +531,11 @@ impl TransferScreen { } impl ScreenLike for TransferScreen { - fn display_message(&mut self, message: &str, message_type: MessageType) { - if let MessageType::Error = message_type { - self.transfer_credits_status = TransferCreditsStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.refresh_banner.take_and_clear(); + self.transfer_credits_status = TransferCreditsStatus::Error; } } @@ -498,6 +543,7 @@ impl ScreenLike for TransferScreen { if let BackendTaskSuccessResult::TransferredCredits(fee_result) = backend_task_success_result { + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.transfer_credits_status = TransferCreditsStatus::Complete; } @@ -505,13 +551,17 @@ impl ScreenLike for TransferScreen { fn refresh(&mut self) { // Refresh the identity because there might be new keys - let identities = match self.app_context.load_local_qualified_identities() { - Ok(list) => list, - Err(e) => { - tracing::warn!("Failed to load identities during refresh: {}", e); - Vec::new() - } - }; + let identities = self + .app_context + .load_local_qualified_identities() + .unwrap_or_else(|e| { + MessageBanner::set_global( + self.app_context.egui_ctx(), + format!("Failed to load local identities: {e}"), + MessageType::Error, + ); + vec![] + }); if let Some(refreshed) = identities .iter() .find(|identity| identity.identity.id() == self.identity.identity.id()) @@ -595,8 +645,11 @@ impl ScreenLike for TransferScreen { if self.selected_wallet.is_some() && let Some(wallet) = &self.selected_wallet { - if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + if !self.wallet_open_attempted { + if let Err(e) = try_open_wallet_no_password(wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + } + self.wallet_open_attempted = true; } if wallet_needs_unlock(wallet) { ui.add_space(10.0); @@ -718,7 +771,7 @@ impl ScreenLike for TransferScreen { && has_enough_balance && !matches!( self.transfer_credits_status, - TransferCreditsStatus::WaitingForResult(_), + TransferCreditsStatus::WaitingForResult, ) && match self.destination_type { TransferDestinationType::Identity => !self.receiver_identity_id.is_empty(), @@ -762,67 +815,7 @@ impl ScreenLike for TransferScreen { }; } - // Handle transfer status messages - ui.add_space(5.0); - match &self.transfer_credits_status { - TransferCreditsStatus::NotStarted => { - // Do nothing - } - TransferCreditsStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed_seconds = now - start_time; - - let display_time = if elapsed_seconds < 60 { - format!( - "{} second{}", - elapsed_seconds, - if elapsed_seconds == 1 { "" } else { "s" } - ) - } else { - let minutes = elapsed_seconds / 60; - let seconds = elapsed_seconds % 60; - format!( - "{} minute{} and {} second{}", - minutes, - if minutes == 1 { "" } else { "s" }, - seconds, - if seconds == 1 { "" } else { "s" } - ) - }; - - ui.label(format!( - "Transferring... Time taken so far: {}", - display_time - )); - } - TransferCreditsStatus::ErrorMessage(msg) => { - let error_color = DashColors::ERROR; - let msg = msg.clone(); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new(format!("Error: {}", msg)).color(error_color), - ); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.transfer_credits_status = - TransferCreditsStatus::NotStarted; - } - }); - }); - } - TransferCreditsStatus::Complete => { - // Handled above - } - } + // Status display is handled by the global MessageBanner } inner_action diff --git a/src/ui/identities/withdraw_screen.rs b/src/ui/identities/withdraw_screen.rs index b3de54760..7ad9abb40 100644 --- a/src/ui/identities/withdraw_screen.rs +++ b/src/ui/identities/withdraw_screen.rs @@ -15,6 +15,7 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt, ResultBannerExt}; use crate::ui::components::{Component, ComponentResponse}; use crate::ui::helpers::{TransactionType, add_key_chooser}; use crate::ui::theme::DashColors; @@ -25,13 +26,11 @@ use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; -use dash_sdk::dpp::prelude::TimestampMillis; use dash_sdk::platform::IdentityPublicKey; use eframe::egui::{self, Context, Frame, Margin, Ui}; use egui::{Color32, RichText}; use std::str::FromStr; use std::sync::{Arc, RwLock}; -use std::time::{SystemTime, UNIX_EPOCH}; use super::get_selected_wallet; use super::keys::add_key_screen::AddKeyScreen; @@ -40,8 +39,8 @@ use super::keys::key_info_screen::KeyInfoScreen; #[derive(PartialEq)] pub enum WithdrawFromIdentityStatus { NotStarted, - WaitingForResult(TimestampMillis), - ErrorMessage(String), + WaitingForResult, + Error, Complete, } @@ -58,10 +57,11 @@ pub struct WithdrawalScreen { withdraw_from_identity_status: WithdrawFromIdentityStatus, selected_wallet: Option>>, wallet_unlock_popup: WalletUnlockPopup, - error_message: Option, + wallet_open_attempted: bool, show_advanced_options: bool, // Fee result from completed operation completed_fee_result: Option, + refresh_banner: Option, } impl WithdrawalScreen { @@ -74,9 +74,9 @@ impl WithdrawalScreen { KeyType::all_key_types().into(), false, ); - let mut error_message = None; - let selected_wallet = - get_selected_wallet(&identity, None, selected_key, &mut error_message); + let selected_wallet = get_selected_wallet(&identity, None, selected_key) + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None); Self { identity, selected_key: selected_key.cloned(), @@ -90,9 +90,10 @@ impl WithdrawalScreen { withdraw_from_identity_status: WithdrawFromIdentityStatus::NotStarted, selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), - error_message, + wallet_open_attempted: false, show_advanced_options: false, completed_fee_result: None, + refresh_banner: None, } } @@ -119,10 +120,10 @@ impl WithdrawalScreen { // Check if input should be disabled when operation is in progress let enabled = match self.withdraw_from_identity_status { - WithdrawFromIdentityStatus::WaitingForResult(_) - | WithdrawFromIdentityStatus::Complete => false, - WithdrawFromIdentityStatus::NotStarted - | WithdrawFromIdentityStatus::ErrorMessage(_) => { + WithdrawFromIdentityStatus::WaitingForResult | WithdrawFromIdentityStatus::Complete => { + false + } + WithdrawFromIdentityStatus::NotStarted | WithdrawFromIdentityStatus::Error => { amount_input.set_max_amount(Some(max_amount_credits)); true } @@ -240,8 +241,11 @@ impl WithdrawalScreen { { format!("masternode payout address {}", payout_address) } else if !self.app_context.is_developer_mode() { - self.withdraw_from_identity_status = WithdrawFromIdentityStatus::ErrorMessage( - "No masternode payout address".to_string(), + self.withdraw_from_identity_status = WithdrawFromIdentityStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "No masternode payout address", + MessageType::Error, ); self.confirmation_dialog = None; return AppAction::None; @@ -250,8 +254,12 @@ impl WithdrawalScreen { }; let Some(selected_key) = self.selected_key.as_ref() else { - self.withdraw_from_identity_status = - WithdrawFromIdentityStatus::ErrorMessage("No selected key".to_string()); + self.withdraw_from_identity_status = WithdrawFromIdentityStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "No selected key", + MessageType::Error, + ); self.confirmation_dialog = None; return AppAction::None; }; @@ -273,12 +281,14 @@ impl WithdrawalScreen { match dialog.show(ui).inner.dialog_response { Some(ConfirmationStatus::Confirmed) => { self.confirmation_dialog = None; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.withdraw_from_identity_status = - WithdrawFromIdentityStatus::WaitingForResult(now); + self.withdraw_from_identity_status = WithdrawFromIdentityStatus::WaitingForResult; + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Withdrawing from identity...", + MessageType::Info, + ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); // Use the amount directly from the stored amount let credits = self @@ -318,10 +328,11 @@ impl WithdrawalScreen { } impl ScreenLike for WithdrawalScreen { - fn display_message(&mut self, message: &str, message_type: MessageType) { - if let MessageType::Error = message_type { - self.withdraw_from_identity_status = - WithdrawFromIdentityStatus::ErrorMessage(message.to_string()); + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.refresh_banner.take_and_clear(); + self.withdraw_from_identity_status = WithdrawFromIdentityStatus::Error; } } @@ -329,6 +340,7 @@ impl ScreenLike for WithdrawalScreen { if let BackendTaskSuccessResult::WithdrewFromIdentity(fee_result) = backend_task_success_result { + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.withdraw_from_identity_status = WithdrawFromIdentityStatus::Complete; } @@ -339,7 +351,14 @@ impl ScreenLike for WithdrawalScreen { if let Some(refreshed) = self .app_context .load_local_qualified_identities() - .unwrap_or_default() + .unwrap_or_else(|e| { + MessageBanner::set_global( + self.app_context.egui_ctx(), + format!("Failed to load local identities: {e}"), + MessageType::Error, + ); + vec![] + }) .into_iter() .find(|identity| identity.identity.id() == self.identity.identity.id()) { @@ -466,15 +485,28 @@ impl ScreenLike for WithdrawalScreen { PrivateKeyTarget::PrivateKeyOnMainIdentity, selected_key.id(), )) { - self.selected_wallet = self + let new_wallet = self .identity .associated_wallets .get(&wallet_derivation_path.wallet_seed_hash) .cloned(); + // Reset guard when wallet changes (different Arc pointer) + let wallet_changed = match (&self.selected_wallet, &new_wallet) { + (Some(a), Some(b)) => !Arc::ptr_eq(a, b), + (None, None) => false, + _ => true, + }; + if wallet_changed { + self.wallet_open_attempted = false; + } + self.selected_wallet = new_wallet; if let Some(wallet) = &self.selected_wallet { - if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + if !self.wallet_open_attempted { + if let Err(e) = try_open_wallet_no_password(wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + } + self.wallet_open_attempted = true; } if wallet_needs_unlock(wallet) { ui.add_space(10.0); @@ -604,71 +636,7 @@ impl ScreenLike for WithdrawalScreen { inner_action |= self.show_confirmation_popup(ui); } - ui.add_space(10.0); - - // Handle withdrawal status messages - match &self.withdraw_from_identity_status { - WithdrawFromIdentityStatus::NotStarted => { - // Do nothing - } - WithdrawFromIdentityStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed_seconds = now.saturating_sub(*start_time); - - let display_time = if elapsed_seconds < 60 { - format!( - "{} second{}", - elapsed_seconds, - if elapsed_seconds == 1 { "" } else { "s" } - ) - } else { - let minutes = elapsed_seconds / 60; - let seconds = elapsed_seconds % 60; - format!( - "{} minute{} and {} second{}", - minutes, - if minutes == 1 { "" } else { "s" }, - seconds, - if seconds == 1 { "" } else { "s" } - ) - }; - - ui.label(format!( - "Withdrawing... Time taken so far: {}", - display_time - )); - } - WithdrawFromIdentityStatus::ErrorMessage(msg) => { - let error_color = DashColors::ERROR; - let msg = msg.clone(); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new(format!("Error: {}", msg)).color(error_color), - ); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.withdraw_from_identity_status = - WithdrawFromIdentityStatus::NotStarted; - } - }); - }); - } - WithdrawFromIdentityStatus::Complete => { - ui.colored_label( - egui::Color32::DARK_GREEN, - "Successfully withdrew from identity".to_string(), - ); - } - } + // Status display is handled by the global MessageBanner } inner_action diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 72cd69854..fbe653e15 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -883,10 +883,21 @@ pub trait ScreenLike { self.refresh() } fn ui(&mut self, ctx: &Context) -> AppAction; + /// Called by `AppState` **after** the global banner has already been set. + /// + /// Override **only for side-effects** such as clearing a progress banner + /// (`self.refresh_banner.take_and_clear()`) or updating an internal status enum. + /// Do **not** set your own banner here — `AppState` already did that. fn display_message(&mut self, _message: &str, _message_type: MessageType) {} - fn display_task_result(&mut self, _backend_task_success_result: BackendTaskSuccessResult) { - self.display_message("Success", MessageType::Success) - } + + /// Called by `AppState` when a backend task completes successfully. + /// + /// Global success/error banners are handled centrally by `AppState::update()`. + /// Override this to perform screen-specific side-effects (e.g., storing a + /// result, transitioning status, clearing a progress banner). + /// The default is a **no-op** — screens that dispatch backend tasks should + /// override this for their expected result variants. + fn display_task_result(&mut self, _backend_task_success_result: BackendTaskSuccessResult) {} fn pop_on_success(&mut self) {} } diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index 83dcb3890..5af952c73 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -20,7 +20,7 @@ use crate::utils::path::format_path_for_display; use dash_sdk::dash_spv::sync::{ProgressPercentage, SyncProgress as SpvSyncProgress, SyncState}; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::identity::TimestampMillis; -use eframe::egui::{self, Color32, Context, Frame, Margin, RichText, Ui}; +use eframe::egui::{self, Context, Ui}; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; @@ -90,7 +90,6 @@ pub struct NetworkChooserScreen { pub current_network: Network, pub recheck_time: Option, custom_dash_qt_path: Option, - custom_dash_qt_error_message: Option, overwrite_dash_conf: bool, disable_zmq: bool, developer_mode: bool, @@ -189,7 +188,6 @@ impl NetworkChooserScreen { current_network, recheck_time: None, custom_dash_qt_path, - custom_dash_qt_error_message: None, overwrite_dash_conf, disable_zmq, developer_mode, @@ -898,11 +896,9 @@ impl NetworkChooserScreen { if ui.button("Select File").clicked() && let Some(path) = rfd::FileDialog::new().pick_file() { + let previous_custom_dash_qt_path = self.custom_dash_qt_path.clone(); let file_name = path.file_name().and_then(|f| f.to_str()); if let Some(file_name) = file_name { - self.custom_dash_qt_path = None; - self.custom_dash_qt_error_message = None; - // Handle macOS .app bundles let resolved_path = if cfg!(target_os = "macos") && path.extension().and_then(|s| s.to_str()) == Some("app") @@ -925,8 +921,15 @@ impl NetworkChooserScreen { if is_valid { self.custom_dash_qt_path = Some(resolved_path); - self.custom_dash_qt_error_message = None; - self.save().expect("Expected to save db settings"); + if let Err(e) = self.save() { + tracing::warn!("Failed to save Dash-Qt path setting: {}", e); + MessageBanner::set_global( + ui.ctx(), + "Failed to save Dash-Qt path setting. Please try again.", + MessageType::Error, + ); + self.custom_dash_qt_path = previous_custom_dash_qt_path; + } } else { let required_file_name = if cfg!(target_os = "windows") { "dash-qt.exe" @@ -935,49 +938,44 @@ impl NetworkChooserScreen { } else { "dash-qt" }; - self.custom_dash_qt_error_message = Some(format!( - "Invalid file: Please select a valid '{}'.", - required_file_name - )); + MessageBanner::set_global( + ui.ctx(), + format!( + "Invalid file: Please select a valid '{}'.", + required_file_name + ), + MessageType::Error, + ); } } } if self.custom_dash_qt_path.is_some() && ui.button("Clear").clicked() { + let previous_custom_dash_qt_path = self.custom_dash_qt_path.clone(); self.custom_dash_qt_path = Some(PathBuf::new()); - self.custom_dash_qt_error_message = None; - self.save().expect("Expected to save db settings"); + if let Err(e) = self.save() { + tracing::warn!("Failed to save cleared Dash-Qt path setting: {}", e); + MessageBanner::set_global( + ui.ctx(), + "Failed to clear Dash-Qt path setting. Please try again.", + MessageType::Error, + ); + self.custom_dash_qt_path = previous_custom_dash_qt_path; + } } }); - if let Some(ref file) = self.custom_dash_qt_path { - if !file.as_os_str().is_empty() { - ui.horizontal(|ui| { - ui.label("Path:"); - ui.label( - egui::RichText::new(format_path_for_display(file)) - .color(DashColors::SUCCESS) - .italics(), - ); - }); - } - } else if let Some(ref error) = self.custom_dash_qt_error_message { - let error_color = Color32::from_rgb(255, 100, 100); - let error = error.clone(); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new(&error).color(error_color)); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.custom_dash_qt_error_message = None; - } - }); - }); + if let Some(ref file) = self.custom_dash_qt_path + && !file.as_os_str().is_empty() + { + ui.horizontal(|ui| { + ui.label("Path:"); + ui.label( + egui::RichText::new(format_path_for_display(file)) + .color(DashColors::SUCCESS) + .italics(), + ); + }); } // Configuration Options @@ -992,11 +990,19 @@ impl NetworkChooserScreen { ui.add_space(8.0); ui.horizontal(|ui| { + let previous_overwrite_dash_conf = self.overwrite_dash_conf; if StyledCheckbox::new(&mut self.overwrite_dash_conf, "Overwrite dash.conf") .show(ui) .clicked() + && let Err(e) = self.save() { - self.save().expect("Expected to save db settings"); + tracing::warn!("Failed to save overwrite_dash_conf setting: {}", e); + MessageBanner::set_global( + ui.ctx(), + "Failed to save overwrite dash.conf setting. Please try again.", + MessageType::Error, + ); + self.overwrite_dash_conf = previous_overwrite_dash_conf; } ui.label( egui::RichText::new("Auto-configure required settings") @@ -2006,12 +2012,12 @@ impl NetworkChooserScreen { "Sync complete".to_string() } else { match progress.state() { - SyncState::WaitingForConnections => "Connecting to peers".to_string(), + SyncState::Initializing | SyncState::WaitingForConnections => { + "Connecting to peers".to_string() + } SyncState::WaitForEvents => "Querying peer heights".to_string(), SyncState::Error => "Sync error".to_string(), - SyncState::Initializing | SyncState::Syncing | SyncState::Synced => { - "Syncing...".to_string() - } + SyncState::Syncing | SyncState::Synced => "Syncing...".to_string(), } }; diff --git a/src/ui/theme.rs b/src/ui/theme.rs index dd186acfd..6f9a38f3a 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -324,6 +324,15 @@ impl DashColors { } } + /// Magenta-red for sync/connection error state (distinct from disconnected red). + pub fn sync_error_color(dark_mode: bool) -> Color32 { + if dark_mode { + Color32::from_rgb(230, 80, 180) + } else { + Color32::from_rgb(200, 50, 150) + } + } + pub fn info_color(dark_mode: bool) -> Color32 { if dark_mode { Color32::from_rgb(100, 180, 255) // Lighter blue for dark mode diff --git a/src/ui/tokens/add_token_by_id_screen.rs b/src/ui/tokens/add_token_by_id_screen.rs index 95d469c42..964784ccd 100644 --- a/src/ui/tokens/add_token_by_id_screen.rs +++ b/src/ui/tokens/add_token_by_id_screen.rs @@ -13,6 +13,7 @@ use eframe::egui::{self, Color32, Context, RichText, Ui}; use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::contract::ContractTask; use crate::database::contracts::InsertTokensToo; +use crate::ui::components::MessageBanner; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; @@ -21,7 +22,7 @@ use crate::{ app::AppAction, backend_task::{BackendTask, tokens::TokenTask}, context::AppContext, - ui::{MessageType, ScreenLike, components::top_panel::add_top_panel, theme::DashColors}, + ui::{MessageType, ScreenLike, components::top_panel::add_top_panel}, }; /// UI state during the add-token flow. @@ -31,7 +32,7 @@ enum AddTokenStatus { Searching(u32), FoundSingle(Box), FoundMultiple(Vec), - Error(String), + Error, Complete, } @@ -44,7 +45,6 @@ pub struct AddTokenByIdScreen { status: AddTokenStatus, selected_token: Option, - error_message: Option, try_token_id_next: bool, } @@ -56,7 +56,6 @@ impl AddTokenByIdScreen { fetched_contract: None, status: AddTokenStatus::Idle, selected_token: None, - error_message: None, try_token_id_next: false, } } @@ -79,7 +78,6 @@ impl AddTokenByIdScreen { { let now = Utc::now().timestamp() as u32; self.status = AddTokenStatus::Searching(now); - self.error_message = None; if !self.contract_or_token_id_input.is_empty() { // Try to parse as identifier @@ -91,7 +89,12 @@ impl AddTokenByIdScreen { TokenTask::FetchTokenByContractId(identifier), ))); } else { - self.status = AddTokenStatus::Error("Invalid identifier format".into()); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Invalid identifier format", + MessageType::Error, + ); + self.status = AddTokenStatus::Error; } } } @@ -196,7 +199,12 @@ impl AddTokenByIdScreen { ) { // 1. Bail out if the contract has no tokens if contract.tokens().is_empty() { - self.status = AddTokenStatus::Error("Contract has no token definitions".into()); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Contract has no token definitions", + MessageType::Error, + ); + self.status = AddTokenStatus::Error; return; } @@ -232,7 +240,12 @@ impl AddTokenByIdScreen { { self.status = AddTokenStatus::FoundSingle(Box::new(token_info)); } else { - self.status = AddTokenStatus::Error("Token position not found in contract".into()); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Token position not found in contract", + MessageType::Error, + ); + self.status = AddTokenStatus::Error; return; } } else if token_infos.len() == 1 { @@ -250,6 +263,7 @@ impl AddTokenByIdScreen { impl ScreenLike for AddTokenByIdScreen { fn display_message(&mut self, msg: &str, msg_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. match msg_type { MessageType::Success => { if msg.contains("DataContract successfully saved") { @@ -262,22 +276,16 @@ impl ScreenLike for AddTokenByIdScreen { // We'll initiate a token ID search self.try_token_id_next = true; } else { - self.status = AddTokenStatus::Error("Contract not found".into()); + self.status = AddTokenStatus::Error; } - } else if msg.contains("Token not found") { - self.status = AddTokenStatus::Error("Token not found".into()); - } else if msg.contains("Error fetching contracts") { - self.status = AddTokenStatus::Error(msg.to_owned()); + } else if msg.contains("Token not found") + || msg.contains("Error fetching contracts") + { + self.status = AddTokenStatus::Error; } } MessageType::Error | MessageType::Warning => { - // Handle any error during the add token process - if msg.contains("Error inserting contract into the database") { - self.status = AddTokenStatus::Error("Failed to add token to database".into()); - } else { - self.status = AddTokenStatus::Error(msg.to_owned()); - } - self.error_message = Some(msg.to_owned()); + self.status = AddTokenStatus::Error; } MessageType::Info => {} } @@ -324,8 +332,6 @@ impl ScreenLike for AddTokenByIdScreen { action |= add_tokens_subscreen_chooser_panel(ctx, &self.app_context); action |= island_central_panel(ctx, |ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; - // If we are in the "Complete" status, just show success screen if self.status == AddTokenStatus::Complete { return self.show_success_screen(ui); @@ -375,26 +381,9 @@ impl ScreenLike for AddTokenByIdScreen { ui.add_space(10.0); self.render_search_results(ui); - if let AddTokenStatus::Error(err) = &self.status { - ui.add_space(10.0); - ui.colored_label( - DashColors::error_color(dark_mode), - format!("Error: {}", err), - ); - } - ui.add_space(10.0); inner_action |= self.render_add_button(ui); - // Show any additional error messages - if let Some(error_msg) = &self.error_message { - ui.add_space(5.0); - ui.colored_label( - DashColors::error_color(dark_mode), - format!("Details: {}", error_msg), - ); - } - inner_action }); diff --git a/src/ui/tokens/burn_tokens_screen.rs b/src/ui/tokens/burn_tokens_screen.rs index 1ca585a5d..b58c0cd6c 100644 --- a/src/ui/tokens/burn_tokens_screen.rs +++ b/src/ui/tokens/burn_tokens_screen.rs @@ -1,14 +1,16 @@ use crate::backend_task::{BackendTaskSuccessResult, FeeResult}; use crate::model::fee_estimation::format_credits_as_dash; +use crate::ui::components::MessageBanner; use crate::ui::components::amount_input::AmountInput; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; -use crate::ui::components::{Component, ComponentResponse}; +use crate::ui::components::{BannerHandle, Component, ComponentResponse, OptionBannerExt}; use crate::ui::helpers::{TransactionType, add_key_chooser, render_group_action_text}; use crate::ui::theme::DashColors; use crate::ui::tokens::tokens_screen::IdentityTokenIdentifier; +use crate::ui::tokens::validate_signing_key; use dash_sdk::dpp::data_contract::GroupContractPosition; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; @@ -25,7 +27,6 @@ use eframe::egui::{Frame, Margin}; use egui::RichText; use std::collections::HashSet; use std::sync::{Arc, RwLock}; -use std::time::{SystemTime, UNIX_EPOCH}; use crate::app::{AppAction, BackendTasksExecutionMode}; use crate::backend_task::BackendTask; @@ -48,8 +49,8 @@ use super::tokens_screen::IdentityTokenInfo; #[derive(PartialEq)] pub enum BurnTokensStatus { NotStarted, - WaitingForResult(u64), - ErrorMessage(String), + WaitingForResult, + Error, Complete, } @@ -63,12 +64,11 @@ pub struct BurnTokensScreen { // The user chooses how many tokens to burn pub amount: Option, - pub amount_input: Option, - pub max_amount: Option, // Maximum amount the user can burn based on their balance + amount_input: Option, + max_amount: Option, // Maximum amount the user can burn based on their balance pub public_note: Option, status: BurnTokensStatus, - error_message: Option, // Basic references pub app_context: Arc, @@ -79,8 +79,11 @@ pub struct BurnTokensScreen { // For password-based wallet unlocking, if needed selected_wallet: Option>>, wallet_unlock_popup: WalletUnlockPopup, + wallet_open_attempted: bool, // Fee result from completed operation completed_fee_result: Option, + // Banner handle for elapsed time display + refresh_banner: Option, } impl BurnTokensScreen { @@ -108,7 +111,7 @@ impl BurnTokensScreen { ) .cloned(); - let mut error_message = None; + let set_error_banner = |msg: &str| super::set_error_banner(app_context, msg); let group = match identity_token_info .token_config @@ -116,32 +119,30 @@ impl BurnTokensScreen { .authorized_to_make_change_action_takers() { AuthorizedActionTakers::NoOne => { - error_message = Some("Burning is not allowed on this token".to_string()); + set_error_banner("Burning is not allowed on this token"); None } AuthorizedActionTakers::ContractOwner => { if identity_token_info.data_contract.contract.owner_id() != identity_token_info.identity.identity.id() { - error_message = Some( - "You are not allowed to burn this token. Only the contract owner is." - .to_string(), + set_error_banner( + "You are not allowed to burn this token. Only the contract owner is.", ); } None } AuthorizedActionTakers::Identity(identifier) => { if identifier != &identity_token_info.identity.identity.id() { - error_message = Some("You are not allowed to burn this token".to_string()); + set_error_banner("You are not allowed to burn this token"); } None } AuthorizedActionTakers::MainGroup => { match identity_token_info.token_config.main_control_group() { None => { - error_message = Some( - "Invalid contract: No main control group, though one should exist" - .to_string(), + set_error_banner( + "Invalid contract: No main control group, though one should exist", ); None } @@ -153,7 +154,7 @@ impl BurnTokensScreen { { Ok(group) => Some((group_pos, group.clone())), Err(e) => { - error_message = Some(format!("Invalid contract: {}", e)); + set_error_banner(&format!("Invalid contract: {}", e)); None } } @@ -168,7 +169,7 @@ impl BurnTokensScreen { { Ok(group) => Some((*group_pos, group.clone())), Err(e) => { - error_message = Some(format!("Invalid contract: {}", e)); + set_error_banner(&format!("Invalid contract: {}", e)); None } } @@ -191,12 +192,12 @@ impl BurnTokensScreen { }; // Attempt to get an unlocked wallet reference - let selected_wallet = get_selected_wallet( - &identity_token_info.identity, - None, - possible_key.as_ref(), - &mut error_message, - ); + let selected_wallet = + get_selected_wallet(&identity_token_info.identity, None, possible_key.as_ref()) + .unwrap_or_else(|e| { + set_error_banner(&e); + None + }); Self { identity_token_info, @@ -210,12 +211,13 @@ impl BurnTokensScreen { max_amount: token_balance, public_note: None, status: BurnTokensStatus::NotStarted, - error_message, app_context: app_context.clone(), confirmation_dialog: None, selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), + wallet_open_attempted: false, completed_fee_result: None, + refresh_banner: None, } } @@ -244,8 +246,12 @@ impl BurnTokensScreen { let amount = match self.amount.as_ref() { Some(amount) if amount.value() > 0 => amount, _ => { - self.error_message = Some("Please enter a valid amount greater than 0.".into()); - self.status = BurnTokensStatus::ErrorMessage("Invalid amount".into()); + self.status = BurnTokensStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Please enter a valid amount greater than 0.", + MessageType::Error, + ); self.confirmation_dialog = None; return AppAction::None; } @@ -262,22 +268,33 @@ impl BurnTokensScreen { match dialog.show(ui).inner.dialog_response { Some(ConfirmationStatus::Confirmed) => { self.confirmation_dialog = None; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.status = BurnTokensStatus::WaitingForResult(now); + + // Validate signing key before transitioning to waiting state + let Some(signing_key) = + validate_signing_key(&self.app_context, self.selected_key.as_ref()) + else { + return AppAction::None; + }; + + self.status = BurnTokensStatus::WaitingForResult; + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Burning tokens...", + MessageType::Info, + ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); // Grab the data contract for this token from the app context let data_contract = Arc::new(self.identity_token_info.data_contract.contract.clone()); - let group_info = if self.group_action_id.is_some() { + let group_info = if let Some(action_id) = self.group_action_id { self.group.as_ref().map(|(pos, _)| { GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( GroupStateTransitionInfo { group_contract_position: *pos, - action_id: self.group_action_id.unwrap(), + action_id, action_is_proposer: false, }, ) @@ -295,7 +312,7 @@ impl BurnTokensScreen { owner_identity: self.identity_token_info.identity.clone(), data_contract, token_position: self.identity_token_info.token_position, - signing_key: self.selected_key.clone().expect("Expected a key"), + signing_key, public_note: if self.group_action_id.is_some() { None } else { @@ -332,15 +349,17 @@ impl BurnTokensScreen { } impl ScreenLike for BurnTokensScreen { - fn display_message(&mut self, message: &str, message_type: MessageType) { - if let MessageType::Error = message_type { - self.status = BurnTokensStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.refresh_banner.take_and_clear(); + self.status = BurnTokensStatus::Error; } } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { if let BackendTaskSuccessResult::BurnedTokens(fee_result) = backend_task_success_result { + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.status = BurnTokensStatus::Complete; } @@ -465,8 +484,11 @@ impl ScreenLike for BurnTokensScreen { } else { // Possibly handle locked wallet scenario if let Some(wallet) = &self.selected_wallet { - if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + if !self.wallet_open_attempted { + if let Err(e) = try_open_wallet_no_password(wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + } + self.wallet_open_attempted = true; } if wallet_needs_unlock(wallet) { ui.add_space(10.0); @@ -652,19 +674,11 @@ impl ScreenLike for BurnTokensScreen { BurnTokensStatus::NotStarted => { // no-op } - BurnTokensStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed = now - start_time; - ui.label(format!("Burning... elapsed: {} seconds", elapsed)); + BurnTokensStatus::WaitingForResult => { + // Elapsed display is handled by the global MessageBanner } - BurnTokensStatus::ErrorMessage(msg) => { - ui.colored_label( - DashColors::error_color(dark_mode), - format!("Error: {}", msg), - ); + BurnTokensStatus::Error => { + // Error display is handled by the global MessageBanner } BurnTokensStatus::Complete => { // handled above diff --git a/src/ui/tokens/claim_tokens_screen.rs b/src/ui/tokens/claim_tokens_screen.rs index 850955b08..a2d02e802 100644 --- a/src/ui/tokens/claim_tokens_screen.rs +++ b/src/ui/tokens/claim_tokens_screen.rs @@ -8,7 +8,7 @@ use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_ use crate::ui::helpers::{TransactionType, add_key_chooser}; use std::collections::HashSet; use std::sync::{Arc, RwLock}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::Duration; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; @@ -32,6 +32,7 @@ use crate::model::qualified_identity::{IdentityType, QualifiedIdentity}; use crate::model::wallet::Wallet; use crate::ui::theme::DashColors; use crate::ui::{MessageType, Screen, ScreenLike}; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{wallet_needs_unlock, try_open_wallet_no_password, WalletUnlockPopup, WalletUnlockResult}; use crate::ui::identities::get_selected_wallet; @@ -43,13 +44,13 @@ use super::tokens_screen::IdentityTokenBasicInfo; #[derive(PartialEq)] pub enum ClaimTokensStatus { NotStarted, - WaitingForResult(u64), - ErrorMessage(String), + WaitingForResult, + Error, Complete, } pub struct ClaimTokensScreen { - pub identity: QualifiedIdentity, + pub identity: Option, pub identity_token_basic_info: IdentityTokenBasicInfo, selected_key: Option, show_advanced_options: bool, @@ -58,10 +59,11 @@ pub struct ClaimTokensScreen { token_configuration: TokenConfiguration, distribution_type: Option, status: ClaimTokensStatus, - error_message: Option, + refresh_banner: Option, pub app_context: Arc, confirmation_dialog: Option, selected_wallet: Option>>, + wallet_open_attempted: bool, wallet_unlock_popup: WalletUnlockPopup, // Fee result from completed operation completed_fee_result: Option, @@ -78,29 +80,32 @@ impl ClaimTokensScreen { .load_local_qualified_identities() .unwrap_or_default() .into_iter() - .find(|id| id.identity.id() == identity_token_basic_info.identity_id) - .expect("No local qualified identity found for this token’s identity."); - - let identity_clone = identity.identity.clone(); - let mut possible_key = identity_clone.get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ); + .find(|id| id.identity.id() == identity_token_basic_info.identity_id); - if possible_key.is_none() { - possible_key = identity_clone.get_first_public_key_matching( - Purpose::TRANSFER, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ); - } + let (selected_key, selected_wallet) = if let Some(ref id) = identity { + let identity_inner = &id.identity; + let key = identity_inner + .get_first_public_key_matching( + Purpose::AUTHENTICATION, + HashSet::from([SecurityLevel::CRITICAL]), + KeyType::all_key_types().into(), + false, + ) + .or_else(|| { + identity_inner.get_first_public_key_matching( + Purpose::TRANSFER, + HashSet::from([SecurityLevel::CRITICAL]), + KeyType::all_key_types().into(), + false, + ) + }) + .cloned(); - let mut error_message = None; - let selected_wallet = - get_selected_wallet(&identity, None, possible_key, &mut error_message); + let selected_wallet = get_selected_wallet(id, None, key.as_ref()).unwrap_or(None); + (key, selected_wallet) + } else { + (None, None) + }; let distribution_type = match ( token_configuration @@ -121,17 +126,18 @@ impl ClaimTokensScreen { Self { identity, identity_token_basic_info, - selected_key: possible_key.cloned(), + selected_key, show_advanced_options: false, public_note: None, token_contract, token_configuration, distribution_type, status: ClaimTokensStatus::NotStarted, - error_message, + refresh_banner: None, app_context: app_context.clone(), confirmation_dialog: None, selected_wallet, + wallet_open_attempted: false, wallet_unlock_popup: WalletUnlockPopup::new(), completed_fee_result: None, } @@ -142,12 +148,13 @@ impl ClaimTokensScreen { .token_configuration .distribution_rules() .perpetual_distribution() + && let Some(identity) = &self.identity { match perpetual_distribution.distribution_recipient() { TokenDistributionRecipient::ContractOwner => { - self.token_contract.contract.owner_id() == self.identity.identity.id() + self.token_contract.contract.owner_id() == identity.identity.id() } - TokenDistributionRecipient::Identity(id) => self.identity.identity.id() == id, + TokenDistributionRecipient::Identity(id) => identity.identity.id() == id, TokenDistributionRecipient::EvonodesByParticipation => true, } } else { @@ -189,6 +196,15 @@ impl ClaimTokensScreen { } fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { + let Some(identity) = self.identity.clone() else { + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Identity not available", + MessageType::Error, + ); + self.status = ClaimTokensStatus::Error; + return AppAction::None; + }; let distribution_type = self .distribution_type .unwrap_or(TokenDistributionType::Perpetual); @@ -207,24 +223,32 @@ impl ClaimTokensScreen { let signing_key = match self.selected_key.clone() { Some(key) => key, None => { - self.error_message = Some("No signing key selected".into()); - self.status = ClaimTokensStatus::ErrorMessage("No key selected".into()); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "No signing key selected", + MessageType::Error, + ); + self.status = ClaimTokensStatus::Error; return AppAction::None; } }; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.status = ClaimTokensStatus::WaitingForResult(now); + self.status = ClaimTokensStatus::WaitingForResult; + self.refresh_banner.take_and_clear(); + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Claiming tokens...", + MessageType::Info, + ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); AppAction::BackendTasks( vec![ BackendTask::TokenTask(Box::new(TokenTask::ClaimTokens { data_contract: Arc::new(self.token_contract.contract.clone()), token_position: self.identity_token_basic_info.token_position, - actor_identity: self.identity.clone(), + actor_identity: identity, distribution_type, signing_key, public_note: self.public_note.clone(), @@ -253,14 +277,16 @@ impl ClaimTokensScreen { } impl ScreenLike for ClaimTokensScreen { - fn display_message(&mut self, message: &str, message_type: MessageType) { - if let MessageType::Error = message_type { - self.status = ClaimTokensStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.refresh_banner.take_and_clear(); + self.status = ClaimTokensStatus::Error; } } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { + self.refresh_banner.take_and_clear(); if let BackendTaskSuccessResult::ClaimedTokens(fee_result) = backend_task_success_result { self.completed_fee_result = Some(fee_result); self.status = ClaimTokensStatus::Complete; @@ -268,12 +294,13 @@ impl ScreenLike for ClaimTokensScreen { } fn refresh(&mut self) { - if let Ok(all) = self.app_context.load_local_qualified_identities() + if let Some(current) = &self.identity + && let Ok(all) = self.app_context.load_local_qualified_identities() && let Some(updated) = all .into_iter() - .find(|id| id.identity.id() == self.identity.identity.id()) + .find(|id| id.identity.id() == current.identity.id()) { - self.identity = updated; + self.identity = Some(updated); } } @@ -308,20 +335,27 @@ impl ScreenLike for ClaimTokensScreen { return; } + let Some(identity) = self.identity.as_ref() else { + ui.colored_label( + egui::Color32::RED, + "Identity not found in local store. Please refresh or re-open this screen.", + ); + return; + }; + ui.heading("Claim Tokens"); ui.add_space(10.0); // Check if user has any auth keys let has_keys = if self.app_context.is_developer_mode() { - !self.identity.identity.public_keys().is_empty() + !identity.identity.public_keys().is_empty() } else { - match self.identity.identity_type { - IdentityType::User => !self - .identity + match identity.identity_type { + IdentityType::User => !identity .available_authentication_keys_with_critical_security_level() .is_empty(), IdentityType::Masternode | IdentityType::Evonode => { - !self.identity.available_transfer_keys().is_empty() + !identity.available_transfer_keys().is_empty() } } }; @@ -331,12 +365,12 @@ impl ScreenLike for ClaimTokensScreen { Color32::RED, format!( "No authentication keys with CRITICAL security level found for this {} identity.", - self.identity.identity_type, + identity.identity_type, ), ); ui.add_space(10.0); - let first_key = self.identity.identity.get_first_public_key_matching( + let first_key = identity.identity.get_first_public_key_matching( Purpose::AUTHENTICATION, HashSet::from([SecurityLevel::CRITICAL]), KeyType::all_key_types().into(), @@ -346,7 +380,7 @@ impl ScreenLike for ClaimTokensScreen { if let Some(key) = first_key { if ui.button("Check Keys").clicked() { action |= AppAction::AddScreen(Screen::KeyInfoScreen(KeyInfoScreen::new( - self.identity.clone(), + identity.clone(), key.clone(), None, &self.app_context, @@ -357,15 +391,23 @@ impl ScreenLike for ClaimTokensScreen { if ui.button("Add key").clicked() { action |= AppAction::AddScreen(Screen::AddKeyScreen(AddKeyScreen::new( - self.identity.clone(), + identity.clone(), &self.app_context, ))); } } else { // Possibly handle locked wallet scenario if let Some(wallet) = &self.selected_wallet { - if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + if !self.wallet_open_attempted { + if let Err(e) = try_open_wallet_no_password(wallet) { + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Unable to open wallet. Please unlock it and try again.", + MessageType::Error, + ) + .with_details(e); + } + self.wallet_open_attempted = true; } if wallet_needs_unlock(wallet) { ui.add_space(10.0); @@ -397,7 +439,7 @@ impl ScreenLike for ClaimTokensScreen { add_key_chooser( ui, &self.app_context, - &self.identity, + identity, &mut self.selected_key, TransactionType::TokenClaim, ); @@ -553,9 +595,12 @@ impl ScreenLike for ClaimTokensScreen { if ui.add(button).clicked() { if self.distribution_type.is_none() { - self.status = ClaimTokensStatus::ErrorMessage( - "Please select a distribution type.".to_string(), + MessageBanner::set_global( + ctx, + "Please select a distribution type.", + MessageType::Error, ); + self.status = ClaimTokensStatus::Error; return; } else if self.confirmation_dialog.is_none() { self.confirmation_dialog = Some(ConfirmationDialog::new( @@ -570,39 +615,8 @@ impl ScreenLike for ClaimTokensScreen { action |= self.show_confirmation_popup(ui); } - ui.add_space(10.0); - match &self.status { - ClaimTokensStatus::NotStarted => {} - ClaimTokensStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed = now - start_time; - ui.label(format!("Claiming... elapsed: {}s", elapsed)); - } - ClaimTokensStatus::ErrorMessage(msg) => { - let error_color = DashColors::ERROR; - let msg = msg.clone(); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new(format!("Error: {}", msg)).color(error_color), - ); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.status = ClaimTokensStatus::NotStarted; - } - }); - }); - } - ClaimTokensStatus::Complete => {} - } + // Status display is handled by the global MessageBanner + // (progress with elapsed timer, errors, etc.) } }); diff --git a/src/ui/tokens/destroy_frozen_funds_screen.rs b/src/ui/tokens/destroy_frozen_funds_screen.rs index 42baa30b3..95bfca2fd 100644 --- a/src/ui/tokens/destroy_frozen_funds_screen.rs +++ b/src/ui/tokens/destroy_frozen_funds_screen.rs @@ -16,11 +16,13 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::helpers::{TransactionType, add_key_chooser, render_group_action_text}; use crate::ui::identities::get_selected_wallet; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; use crate::ui::theme::DashColors; +use crate::ui::tokens::validate_signing_key; use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dpp::data_contract::GroupContractPosition; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -37,21 +39,20 @@ use eframe::egui::{self, Color32, Context, Frame, Margin, Ui}; use egui::RichText; use std::collections::HashSet; use std::sync::{Arc, RwLock}; -use std::time::{SystemTime, UNIX_EPOCH}; /// Represents possible states in the “destroy frozen funds” flow #[derive(PartialEq)] pub enum DestroyFrozenFundsStatus { NotStarted, - WaitingForResult(u64), - ErrorMessage(String), + WaitingForResult, + Error, Complete, } /// A screen for destroying frozen funds of a particular token contract pub struct DestroyFrozenFundsScreen { /// Identity that is authorized to destroy - pub identity: QualifiedIdentity, + identity: QualifiedIdentity, /// Info on which token contract we're dealing with pub identity_token_info: IdentityTokenInfo, @@ -73,10 +74,9 @@ pub struct DestroyFrozenFundsScreen { /// All frozen identities that can be selected /// TODO: We should filter them by frozen status, right now we just show all known identities - pub frozen_identities: Vec, + frozen_identities: Vec, status: DestroyFrozenFundsStatus, - error_message: Option, /// Basic references pub app_context: Arc, @@ -87,8 +87,11 @@ pub struct DestroyFrozenFundsScreen { /// If password-based wallet unlocking is needed selected_wallet: Option>>, wallet_unlock_popup: WalletUnlockPopup, + wallet_open_attempted: bool, /// Fee result from completed operation completed_fee_result: Option, + /// Banner handle for elapsed time display + refresh_banner: Option, } impl DestroyFrozenFundsScreen { @@ -104,7 +107,7 @@ impl DestroyFrozenFundsScreen { ) .cloned(); - let mut error_message = None; + let set_error_banner = |msg: &str| super::set_error_banner(app_context, msg); let group = match identity_token_info .token_config @@ -112,32 +115,30 @@ impl DestroyFrozenFundsScreen { .authorized_to_make_change_action_takers() { AuthorizedActionTakers::NoOne => { - error_message = Some("Burning is not allowed on this token".to_string()); + set_error_banner("Destroying frozen funds is not allowed on this token"); None } AuthorizedActionTakers::ContractOwner => { if identity_token_info.data_contract.contract.owner_id() != identity_token_info.identity.identity.id() { - error_message = Some( - "You are not allowed to burn this token. Only the contract owner is." - .to_string(), + set_error_banner( + "You are not allowed to destroy frozen funds on this token. Only the contract owner is.", ); } None } AuthorizedActionTakers::Identity(identifier) => { if identifier != &identity_token_info.identity.identity.id() { - error_message = Some("You are not allowed to burn this token".to_string()); + set_error_banner("You are not allowed to destroy frozen funds on this token"); } None } AuthorizedActionTakers::MainGroup => { match identity_token_info.token_config.main_control_group() { None => { - error_message = Some( - "Invalid contract: No main control group, though one should exist" - .to_string(), + set_error_banner( + "Invalid contract: No main control group, though one should exist", ); None } @@ -149,7 +150,7 @@ impl DestroyFrozenFundsScreen { { Ok(group) => Some((group_pos, group.clone())), Err(e) => { - error_message = Some(format!("Invalid contract: {}", e)); + set_error_banner(&format!("Invalid contract: {}", e)); None } } @@ -164,7 +165,7 @@ impl DestroyFrozenFundsScreen { { Ok(group) => Some((*group_pos, group.clone())), Err(e) => { - error_message = Some(format!("Invalid contract: {}", e)); + set_error_banner(&format!("Invalid contract: {}", e)); None } } @@ -187,16 +188,14 @@ impl DestroyFrozenFundsScreen { }; // Attempt to get an unlocked wallet reference - let selected_wallet = get_selected_wallet( - &identity_token_info.identity, - None, - possible_key.as_ref(), - &mut error_message, - ); + let selected_wallet = + get_selected_wallet(&identity_token_info.identity, None, possible_key.as_ref()) + .unwrap_or_else(|e| { + set_error_banner(&e); + None + }); - let all_identities = app_context - .load_local_qualified_identities() - .expect("Identities not loaded"); + let all_identities = super::load_identities_with_banner(app_context); Self { identity: identity_token_info.identity.clone(), @@ -210,12 +209,13 @@ impl DestroyFrozenFundsScreen { group_action_id: None, public_note: None, status: DestroyFrozenFundsStatus::NotStarted, - error_message, app_context: app_context.clone(), confirmation_dialog: None, selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), + wallet_open_attempted: false, completed_fee_result: None, + refresh_banner: None, } } @@ -260,35 +260,36 @@ impl DestroyFrozenFundsScreen { } fn confirmation_ok(&mut self) -> AppAction { - let signing_key = match self.selected_key.clone() { - Some(key) => key, - None => { - self.error_message = Some("No signing key selected".into()); - self.status = DestroyFrozenFundsStatus::ErrorMessage("No key selected".into()); - return AppAction::None; - } - }; - - let frozen_id = match Identifier::from_string_try_encodings( + let Ok(frozen_id) = Identifier::from_string_try_encodings( &self.frozen_identity_id, &[ dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58, dash_sdk::dpp::platform_value::string_encoding::Encoding::Hex, ], - ) { - Ok(id) => id, - Err(_) => { - self.error_message = Some("Invalid frozen identity format".into()); - self.status = DestroyFrozenFundsStatus::ErrorMessage("Invalid identity".into()); - return AppAction::None; - } + ) else { + self.status = DestroyFrozenFundsStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Invalid frozen identity format", + MessageType::Error, + ); + return AppAction::None; + }; + + // Validate signing key before transitioning to waiting state + let Some(signing_key) = validate_signing_key(&self.app_context, self.selected_key.as_ref()) + else { + return AppAction::None; }; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.status = DestroyFrozenFundsStatus::WaitingForResult(now); + self.status = DestroyFrozenFundsStatus::WaitingForResult; + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Destroying frozen funds...", + MessageType::Info, + ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); let data_contract = Arc::new(self.identity_token_info.data_contract.contract.clone()); @@ -339,10 +340,11 @@ impl DestroyFrozenFundsScreen { } impl ScreenLike for DestroyFrozenFundsScreen { - fn display_message(&mut self, message: &str, message_type: MessageType) { - if let MessageType::Error = message_type { - self.status = DestroyFrozenFundsStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.refresh_banner.take_and_clear(); + self.status = DestroyFrozenFundsStatus::Error; } } @@ -350,6 +352,7 @@ impl ScreenLike for DestroyFrozenFundsScreen { if let BackendTaskSuccessResult::DestroyedFrozenFunds(fee_result) = backend_task_success_result { + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.status = DestroyFrozenFundsStatus::Complete; } @@ -464,8 +467,11 @@ impl ScreenLike for DestroyFrozenFundsScreen { } else { // Possibly handle locked wallet scenario if let Some(wallet) = &self.selected_wallet { - if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + if !self.wallet_open_attempted { + if let Err(e) = try_open_wallet_no_password(wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + } + self.wallet_open_attempted = true; } if wallet_needs_unlock(wallet) { ui.add_space(10.0); @@ -622,22 +628,11 @@ impl ScreenLike for DestroyFrozenFundsScreen { DestroyFrozenFundsStatus::NotStarted => { // no-op } - DestroyFrozenFundsStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed = now - start_time; - ui.label(format!( - "Destroying frozen funds... elapsed: {} seconds", - elapsed - )); + DestroyFrozenFundsStatus::WaitingForResult => { + // Elapsed display is handled by the global MessageBanner } - DestroyFrozenFundsStatus::ErrorMessage(msg) => { - ui.colored_label( - DashColors::error_color(dark_mode), - format!("Error: {}", msg), - ); + DestroyFrozenFundsStatus::Error => { + // Error display is handled by the global MessageBanner } DestroyFrozenFundsStatus::Complete => { // handled above diff --git a/src/ui/tokens/direct_token_purchase_screen.rs b/src/ui/tokens/direct_token_purchase_screen.rs index 700761815..65ab6e829 100644 --- a/src/ui/tokens/direct_token_purchase_screen.rs +++ b/src/ui/tokens/direct_token_purchase_screen.rs @@ -1,6 +1,5 @@ use std::collections::HashSet; use std::sync::{Arc, RwLock}; -use std::time::{SystemTime, UNIX_EPOCH}; use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; use dash_sdk::dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; @@ -27,12 +26,14 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::components::{Component, ComponentResponse}; use crate::ui::helpers::{TransactionType, add_key_chooser}; use crate::ui::identities::get_selected_wallet; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; use crate::ui::theme::DashColors; +use crate::ui::tokens::validate_signing_key; use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; @@ -42,8 +43,8 @@ use dash_sdk::platform::IdentityPublicKey; #[derive(PartialEq)] pub enum PurchaseTokensStatus { NotStarted, - WaitingForResult(u64), // Use seconds or millis - ErrorMessage(String), + WaitingForResult, + Error, Complete, } @@ -65,13 +66,15 @@ pub struct PurchaseTokenScreen { /// Screen stuff confirmation_dialog: Option, status: PurchaseTokensStatus, - error_message: Option, // Wallet fields selected_wallet: Option>>, wallet_unlock_popup: WalletUnlockPopup, + wallet_open_attempted: bool, // Fee result from completed operation completed_fee_result: Option, + // Banner handle for elapsed time display + refresh_banner: Option, } impl PurchaseTokenScreen { @@ -87,15 +90,13 @@ impl PurchaseTokenScreen { ) .cloned(); - let mut error_message = None; - // Attempt to get an unlocked wallet reference - let selected_wallet = get_selected_wallet( - &identity_token_info.identity, - None, - possible_key.as_ref(), - &mut error_message, - ); + let selected_wallet = + get_selected_wallet(&identity_token_info.identity, None, possible_key.as_ref()) + .unwrap_or_else(|e| { + MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); + None + }); Self { identity_token_info, @@ -107,12 +108,13 @@ impl PurchaseTokenScreen { calculated_price_credits: None, pricing_fetch_attempted: false, status: PurchaseTokensStatus::NotStarted, - error_message: None, app_context: app_context.clone(), confirmation_dialog: None, selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), + wallet_open_attempted: false, completed_fee_result: None, + refresh_banner: None, } } @@ -160,9 +162,11 @@ impl PurchaseTokenScreen { TokenTask::QueryTokenPricing(token_id), ))); } else { - self.error_message = Some("Failed to get token ID from contract".to_string()); - self.status = PurchaseTokensStatus::ErrorMessage( - "Failed to get token ID from contract".to_string(), + self.status = PurchaseTokensStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Failed to get token ID from contract", + MessageType::Error, ); } } @@ -264,16 +268,23 @@ impl PurchaseTokenScreen { /// Renders a confirm popup with the final "Are you sure?" step fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { let Some(amount) = self.amount_to_purchase_value.as_ref() else { - self.error_message = Some("Please enter a valid amount.".into()); - self.status = PurchaseTokensStatus::ErrorMessage("Invalid amount".into()); + self.status = PurchaseTokensStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Please enter a valid amount.", + MessageType::Error, + ); self.confirmation_dialog = None; return AppAction::None; }; let Some(total_price_credits) = self.calculated_price_credits else { - self.error_message = - Some("Cannot calculate total price. Please fetch token pricing first.".into()); - self.status = PurchaseTokensStatus::ErrorMessage("No pricing fetched".into()); + self.status = PurchaseTokensStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Cannot calculate total price. Please fetch token pricing first.", + MessageType::Error, + ); self.confirmation_dialog = None; return AppAction::None; }; @@ -284,12 +295,21 @@ impl PurchaseTokenScreen { match dialog.show(ui).inner.dialog_response { Some(ConfirmationStatus::Confirmed) => { + let Some(signing_key) = + validate_signing_key(&self.app_context, self.selected_key.as_ref()) + else { + return AppAction::None; + }; + self.confirmation_dialog = None; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.status = PurchaseTokensStatus::WaitingForResult(now); + self.status = PurchaseTokensStatus::WaitingForResult; + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Purchasing tokens...", + MessageType::Info, + ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); AppAction::BackendTasks( vec![ @@ -299,7 +319,7 @@ impl PurchaseTokenScreen { self.identity_token_info.data_contract.contract.clone(), ), token_position: self.identity_token_info.token_position, - signing_key: self.selected_key.clone().expect("Expected a key"), + signing_key, amount: amount.value(), total_agreed_price: total_price_credits, })), @@ -341,17 +361,16 @@ impl ScreenLike for PurchaseTokenScreen { self.status = PurchaseTokensStatus::NotStarted; } else { // No pricing schedule found - token is not for sale - self.status = PurchaseTokensStatus::ErrorMessage( - "This token is not available for direct purchase. No pricing has been set." - .to_string(), - ); - self.error_message = Some( - "This token is not available for direct purchase. No pricing has been set." - .to_string(), + self.status = PurchaseTokensStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "This token is not available for direct purchase. No pricing has been set.", + MessageType::Error, ); } } BackendTaskSuccessResult::PurchasedTokens(fee_result) => { + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.status = PurchaseTokensStatus::Complete; } @@ -359,10 +378,11 @@ impl ScreenLike for PurchaseTokenScreen { } } - fn display_message(&mut self, message: &str, message_type: MessageType) { - if let MessageType::Error = message_type { - self.status = PurchaseTokensStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.refresh_banner.take_and_clear(); + self.status = PurchaseTokensStatus::Error; } } @@ -470,8 +490,11 @@ impl ScreenLike for PurchaseTokenScreen { } else { // Possibly handle locked wallet scenario (similar to TransferTokens) if let Some(wallet) = &self.selected_wallet { - if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + if !self.wallet_open_attempted { + if let Err(e) = try_open_wallet_no_password(wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + } + self.wallet_open_attempted = true; } if wallet_needs_unlock(wallet) { ui.add_space(10.0); @@ -601,12 +624,12 @@ impl ScreenLike for PurchaseTokenScreen { ), )); } else { - self.error_message = Some( - "Cannot calculate total price. Please fetch token pricing first." - .into(), + self.status = PurchaseTokensStatus::Error; + MessageBanner::set_global( + ui.ctx(), + "Cannot calculate total price. Please fetch token pricing first.", + MessageType::Error, ); - self.status = - PurchaseTokensStatus::ErrorMessage("No pricing fetched".into()); } } } else { @@ -636,19 +659,11 @@ impl ScreenLike for PurchaseTokenScreen { PurchaseTokensStatus::NotStarted => { // no-op } - PurchaseTokensStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed = now - start_time; - ui.label(format!("Purchasing... elapsed: {} seconds", elapsed)); + PurchaseTokensStatus::WaitingForResult => { + // Elapsed display is handled by the global MessageBanner } - PurchaseTokensStatus::ErrorMessage(msg) => { - ui.colored_label( - DashColors::error_color(dark_mode), - format!("Error: {}", msg), - ); + PurchaseTokensStatus::Error => { + // Error display is handled by the global MessageBanner } PurchaseTokensStatus::Complete => { // handled above diff --git a/src/ui/tokens/freeze_tokens_screen.rs b/src/ui/tokens/freeze_tokens_screen.rs index 0a45fd075..bc82cfed3 100644 --- a/src/ui/tokens/freeze_tokens_screen.rs +++ b/src/ui/tokens/freeze_tokens_screen.rs @@ -16,11 +16,13 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::helpers::{TransactionType, add_key_chooser, render_group_action_text}; use crate::ui::identities::get_selected_wallet; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; use crate::ui::theme::DashColors; +use crate::ui::tokens::validate_signing_key; use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dpp::data_contract::GroupContractPosition; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -37,20 +39,19 @@ use eframe::egui::{self, Color32, Context, Frame, Margin, Ui}; use egui::RichText; use std::collections::HashSet; use std::sync::{Arc, RwLock}; -use std::time::{SystemTime, UNIX_EPOCH}; /// Internal states for the freeze operation #[derive(PartialEq)] pub enum FreezeTokensStatus { NotStarted, - WaitingForResult(u64), - ErrorMessage(String), + WaitingForResult, + Error, Complete, } /// A UI Screen that allows freezing an identity’s tokens for a particular contract pub struct FreezeTokensScreen { - pub identity: QualifiedIdentity, + identity: QualifiedIdentity, pub identity_token_info: IdentityTokenInfo, selected_key: Option, show_advanced_options: bool, @@ -65,7 +66,6 @@ pub struct FreezeTokensScreen { pub freeze_identity_id: String, status: FreezeTokensStatus, - error_message: Option, // Basic references pub app_context: Arc, @@ -76,15 +76,16 @@ pub struct FreezeTokensScreen { // If password-based wallet unlocking is needed selected_wallet: Option>>, wallet_unlock_popup: WalletUnlockPopup, + wallet_open_attempted: bool, // Fee result from completed operation completed_fee_result: Option, + // Banner handle for elapsed time display + refresh_banner: Option, } impl FreezeTokensScreen { pub fn new(identity_token_info: IdentityTokenInfo, app_context: &Arc) -> Self { - let known_identities = app_context - .load_local_qualified_identities() - .expect("Identities not loaded"); + let known_identities = super::load_identities_with_banner(app_context); let possible_key = identity_token_info .identity @@ -97,7 +98,7 @@ impl FreezeTokensScreen { ) .cloned(); - let mut error_message = None; + let set_error_banner = |msg: &str| super::set_error_banner(app_context, msg); let group = match identity_token_info .token_config @@ -105,32 +106,30 @@ impl FreezeTokensScreen { .authorized_to_make_change_action_takers() { AuthorizedActionTakers::NoOne => { - error_message = Some("Burning is not allowed on this token".to_string()); + set_error_banner("Freezing is not allowed on this token"); None } AuthorizedActionTakers::ContractOwner => { if identity_token_info.data_contract.contract.owner_id() != identity_token_info.identity.identity.id() { - error_message = Some( - "You are not allowed to burn this token. Only the contract owner is." - .to_string(), + set_error_banner( + "You are not allowed to freeze this token. Only the contract owner is.", ); } None } AuthorizedActionTakers::Identity(identifier) => { if identifier != &identity_token_info.identity.identity.id() { - error_message = Some("You are not allowed to burn this token".to_string()); + set_error_banner("You are not allowed to freeze this token"); } None } AuthorizedActionTakers::MainGroup => { match identity_token_info.token_config.main_control_group() { None => { - error_message = Some( - "Invalid contract: No main control group, though one should exist" - .to_string(), + set_error_banner( + "Invalid contract: No main control group, though one should exist", ); None } @@ -142,7 +141,7 @@ impl FreezeTokensScreen { { Ok(group) => Some((group_pos, group.clone())), Err(e) => { - error_message = Some(format!("Invalid contract: {}", e)); + set_error_banner(&format!("Invalid contract: {}", e)); None } } @@ -157,7 +156,7 @@ impl FreezeTokensScreen { { Ok(group) => Some((*group_pos, group.clone())), Err(e) => { - error_message = Some(format!("Invalid contract: {}", e)); + set_error_banner(&format!("Invalid contract: {}", e)); None } } @@ -180,12 +179,12 @@ impl FreezeTokensScreen { }; // Attempt to get an unlocked wallet reference - let selected_wallet = get_selected_wallet( - &identity_token_info.identity, - None, - possible_key.as_ref(), - &mut error_message, - ); + let selected_wallet = + get_selected_wallet(&identity_token_info.identity, None, possible_key.as_ref()) + .unwrap_or_else(|e| { + set_error_banner(&e); + None + }); Self { identity: identity_token_info.identity.clone(), @@ -198,13 +197,14 @@ impl FreezeTokensScreen { public_note: None, freeze_identity_id: String::new(), status: FreezeTokensStatus::NotStarted, - error_message, app_context: app_context.clone(), confirmation_dialog: None, selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), + wallet_open_attempted: false, known_identities, completed_fee_result: None, + refresh_banner: None, } } @@ -250,36 +250,37 @@ impl FreezeTokensScreen { /// Handle confirmation OK action fn confirmation_ok(&mut self) -> AppAction { - let signing_key = match self.selected_key.clone() { - Some(key) => key, - None => { - self.error_message = Some("No signing key selected".into()); - self.status = FreezeTokensStatus::ErrorMessage("No key selected".into()); - return AppAction::None; - } - }; - // Validate user input - let freeze_id = match Identifier::from_string_try_encodings( + let Ok(freeze_id) = Identifier::from_string_try_encodings( &self.freeze_identity_id, &[ dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58, dash_sdk::dpp::platform_value::string_encoding::Encoding::Hex, ], - ) { - Ok(id) => id, - Err(_) => { - self.error_message = Some("Please enter a valid identity ID.".into()); - self.status = FreezeTokensStatus::ErrorMessage("Invalid identity".into()); - return AppAction::None; - } + ) else { + self.status = FreezeTokensStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Please enter a valid identity ID.", + MessageType::Error, + ); + return AppAction::None; + }; + + // Validate signing key before transitioning to waiting state + let Some(signing_key) = validate_signing_key(&self.app_context, self.selected_key.as_ref()) + else { + return AppAction::None; }; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.status = FreezeTokensStatus::WaitingForResult(now); + self.status = FreezeTokensStatus::WaitingForResult; + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Freezing tokens...", + MessageType::Info, + ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); // Grab the data contract for this token from the app context let data_contract = Arc::new(self.identity_token_info.data_contract.contract.clone()); @@ -331,15 +332,17 @@ impl FreezeTokensScreen { } impl ScreenLike for FreezeTokensScreen { - fn display_message(&mut self, message: &str, message_type: MessageType) { - if let MessageType::Error = message_type { - self.status = FreezeTokensStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.refresh_banner.take_and_clear(); + self.status = FreezeTokensStatus::Error; } } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { if let BackendTaskSuccessResult::FrozeTokens(fee_result) = backend_task_success_result { + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.status = FreezeTokensStatus::Complete; } @@ -451,8 +454,11 @@ impl ScreenLike for FreezeTokensScreen { } else { // Possibly handle locked wallet scenario if let Some(wallet) = &self.selected_wallet { - if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + if !self.wallet_open_attempted { + if let Err(e) = try_open_wallet_no_password(wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + } + self.wallet_open_attempted = true; } if wallet_needs_unlock(wallet) { ui.add_space(10.0); @@ -620,33 +626,11 @@ impl ScreenLike for FreezeTokensScreen { FreezeTokensStatus::NotStarted => { // no-op } - FreezeTokensStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed = now - start_time; - ui.label(format!("Freezing... elapsed: {}s", elapsed)); + FreezeTokensStatus::WaitingForResult => { + // Elapsed display is handled by the global MessageBanner } - FreezeTokensStatus::ErrorMessage(msg) => { - let error_color = DashColors::ERROR; - let msg = msg.clone(); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new(format!("Error: {}", msg)).color(error_color), - ); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.status = FreezeTokensStatus::NotStarted; - } - }); - }); + FreezeTokensStatus::Error => { + // Error display is handled by the global MessageBanner } FreezeTokensStatus::Complete => { // handled above diff --git a/src/ui/tokens/mint_tokens_screen.rs b/src/ui/tokens/mint_tokens_screen.rs index 782624e72..c7a567cc3 100644 --- a/src/ui/tokens/mint_tokens_screen.rs +++ b/src/ui/tokens/mint_tokens_screen.rs @@ -7,7 +7,6 @@ use crate::model::amount::Amount; use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; -use crate::ui::components::MessageBanner; use crate::ui::components::amount_input::AmountInput; use crate::ui::components::component_trait::{Component, ComponentResponse}; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; @@ -19,11 +18,13 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::helpers::{TransactionType, add_key_chooser, render_group_action_text}; use crate::ui::identities::get_selected_wallet; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; use crate::ui::theme::DashColors; +use crate::ui::tokens::validate_signing_key; use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dpp::data_contract::GroupContractPosition; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -42,13 +43,11 @@ use eframe::egui::{Frame, Margin}; use egui::RichText; use std::collections::HashSet; use std::sync::{Arc, RwLock}; -use std::time::{SystemTime, UNIX_EPOCH}; - /// Internal states for the mint process. #[derive(PartialEq)] pub enum MintTokensStatus { NotStarted, - WaitingForResult(u64), // Use seconds or millis + WaitingForResult, Error, Complete, } @@ -64,10 +63,10 @@ pub struct MintTokensScreen { pub group_action_id: Option, known_identities: Vec, - pub recipient_identity_id: String, + recipient_identity_id: String, pub amount: Option, - pub amount_input: Option, + amount_input: Option, status: MintTokensStatus, /// Basic references @@ -79,15 +78,16 @@ pub struct MintTokensScreen { // If needed for password-based wallet unlocking: selected_wallet: Option>>, wallet_unlock_popup: WalletUnlockPopup, + wallet_open_attempted: bool, // Fee result from completed operation completed_fee_result: Option, + // Banner handle for elapsed time display + refresh_banner: Option, } impl MintTokensScreen { pub fn new(identity_token_info: IdentityTokenInfo, app_context: &Arc) -> Self { - let known_identities = app_context - .load_local_qualified_identities() - .expect("Identities not loaded"); + let known_identities = super::load_identities_with_banner(app_context); let possible_key = identity_token_info .identity @@ -100,9 +100,7 @@ impl MintTokensScreen { ) .cloned(); - let set_error_banner = |msg: &str| { - MessageBanner::set_global(app_context.egui_ctx(), msg, MessageType::Error); - }; + let set_error_banner = |msg: &str| super::set_error_banner(app_context, msg); let group = match identity_token_info .token_config @@ -183,16 +181,12 @@ impl MintTokensScreen { }; // Attempt to get an unlocked wallet reference - let mut wallet_error = None; - let selected_wallet = get_selected_wallet( - &identity_token_info.identity, - None, - possible_key.as_ref(), - &mut wallet_error, - ); - if let Some(e) = wallet_error { - set_error_banner(&e); - } + let selected_wallet = + get_selected_wallet(&identity_token_info.identity, None, possible_key.as_ref()) + .unwrap_or_else(|e| { + set_error_banner(&e); + None + }); Self { identity_token_info, @@ -211,7 +205,9 @@ impl MintTokensScreen { confirmation_dialog: None, selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), + wallet_open_attempted: false, completed_fee_result: None, + refresh_banner: None, } } @@ -226,7 +222,7 @@ impl MintTokensScreen { // Check if input should be disabled when operation is in progress let enabled = match self.status { - MintTokensStatus::WaitingForResult(_) | MintTokensStatus::Complete => false, + MintTokensStatus::WaitingForResult | MintTokensStatus::Complete => false, MintTokensStatus::NotStarted | MintTokensStatus::Error => true, }; @@ -281,19 +277,6 @@ impl MintTokensScreen { } fn confirmation_ok(&mut self) -> AppAction { - let signing_key = match self.selected_key.clone() { - Some(key) => key, - None => { - self.status = MintTokensStatus::Error; - MessageBanner::set_global( - self.app_context.egui_ctx(), - "No signing key selected", - MessageType::Error, - ); - return AppAction::None; - } - }; - if self.amount.is_none() || self.amount == Some(Amount::new(0, 0)) { self.status = MintTokensStatus::Error; MessageBanner::set_global( @@ -304,30 +287,36 @@ impl MintTokensScreen { return AppAction::None; } - let receiver_id = match Identifier::from_string_try_encodings( + let Ok(receiver_id) = Identifier::from_string_try_encodings( &self.recipient_identity_id, &[ dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58, dash_sdk::dpp::platform_value::string_encoding::Encoding::Hex, ], - ) { - Ok(id) => id, - Err(_) => { - self.status = MintTokensStatus::Error; - MessageBanner::set_global( - self.app_context.egui_ctx(), - "Invalid receiver", - MessageType::Error, - ); - return AppAction::None; - } + ) else { + self.status = MintTokensStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Invalid receiver", + MessageType::Error, + ); + return AppAction::None; + }; + + // Validate signing key before transitioning to waiting state + let Some(signing_key) = validate_signing_key(&self.app_context, self.selected_key.as_ref()) + else { + return AppAction::None; }; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.status = MintTokensStatus::WaitingForResult(now); + self.status = MintTokensStatus::WaitingForResult; + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Minting tokens...", + MessageType::Info, + ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); let data_contract = Arc::new(self.identity_token_info.data_contract.contract.clone()); @@ -378,15 +367,16 @@ impl MintTokensScreen { impl ScreenLike for MintTokensScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { - // Global banner is set by AppState before calling display_message; this only updates status. + // Banner display is handled globally by AppState; this is only for side-effects. if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.refresh_banner.take_and_clear(); self.status = MintTokensStatus::Error; } - // Success/Info: no local state change needed; the global banner is the display mechanism. } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { if let BackendTaskSuccessResult::MintedTokens(fee_result) = backend_task_success_result { + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.status = MintTokensStatus::Complete; } @@ -511,8 +501,11 @@ impl ScreenLike for MintTokensScreen { } else { // Possibly handle locked wallet scenario (similar to TransferTokens) if let Some(wallet) = &self.selected_wallet { - if let Err(e) = try_open_wallet_no_password(wallet) { - MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + if !self.wallet_open_attempted { + if let Err(e) = try_open_wallet_no_password(wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + } + self.wallet_open_attempted = true; } if wallet_needs_unlock(wallet) { ui.add_space(10.0); @@ -718,16 +711,9 @@ impl ScreenLike for MintTokensScreen { // Show in-progress or error messages ui.add_space(10.0); match &self.status { - MintTokensStatus::NotStarted => { - // no-op - } - MintTokensStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed = now - start_time; - ui.label(format!("Minting... elapsed: {} seconds", elapsed)); + MintTokensStatus::NotStarted => {} + MintTokensStatus::WaitingForResult => { + // Elapsed display is handled by the global MessageBanner } MintTokensStatus::Error => { // Error display is handled by the global MessageBanner diff --git a/src/ui/tokens/mod.rs b/src/ui/tokens/mod.rs index 0143d33d1..66ee31426 100644 --- a/src/ui/tokens/mod.rs +++ b/src/ui/tokens/mod.rs @@ -13,3 +13,47 @@ pub mod transfer_tokens_screen; pub mod unfreeze_tokens_screen; pub mod update_token_config; pub mod view_token_claims_screen; + +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; +use crate::ui::MessageType; +use crate::ui::components::MessageBanner; +use dash_sdk::platform::IdentityPublicKey; + +/// Loads local identities, displaying an error banner on failure. +pub fn load_identities_with_banner(app_context: &AppContext) -> Vec { + use crate::ui::components::ResultBannerExt; + app_context + .load_local_qualified_identities() + .or_show_error(app_context.egui_ctx()) + .unwrap_or_default() +} + +/// Convenience wrapper for setting an error banner from a screen constructor. +/// +/// Used by token screen constructors to report configuration errors +/// (e.g., "Burning is not allowed on this token") during initialization. +pub fn set_error_banner(app_context: &AppContext, msg: &str) { + MessageBanner::set_global(app_context.egui_ctx(), msg, MessageType::Error); +} + +/// Validates that a signing key is selected before dispatching a backend task. +/// +/// Returns the signing key on success, or sets a global error banner and returns +/// `None` so callers can bail out early with `let Some(key) = ... else { return; }`. +pub fn validate_signing_key( + app_context: &AppContext, + selected_key: Option<&IdentityPublicKey>, +) -> Option { + match selected_key { + Some(key) => Some(key.clone()), + None => { + MessageBanner::set_global( + app_context.egui_ctx(), + "No signing key selected", + MessageType::Error, + ); + None + } + } +} diff --git a/src/ui/tokens/pause_tokens_screen.rs b/src/ui/tokens/pause_tokens_screen.rs index 5e7d023ac..8ddd16b99 100644 --- a/src/ui/tokens/pause_tokens_screen.rs +++ b/src/ui/tokens/pause_tokens_screen.rs @@ -15,11 +15,13 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::helpers::{TransactionType, add_key_chooser, render_group_action_text}; use crate::ui::identities::get_selected_wallet; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; use crate::ui::theme::DashColors; +use crate::ui::tokens::validate_signing_key; use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dpp::data_contract::GroupContractPosition; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -36,20 +38,19 @@ use eframe::egui::{self, Color32, Context, Frame, Margin, Ui}; use egui::RichText; use std::collections::HashSet; use std::sync::{Arc, RwLock}; -use std::time::{SystemTime, UNIX_EPOCH}; /// Represents states for the pause flow #[derive(PartialEq)] pub enum PauseTokensStatus { NotStarted, - WaitingForResult(u64), - ErrorMessage(String), + WaitingForResult, + Error, Complete, } /// A UI screen that allows pausing all token-related actions for a contract pub struct PauseTokensScreen { - pub identity: QualifiedIdentity, + identity: QualifiedIdentity, pub identity_token_info: IdentityTokenInfo, selected_key: Option, show_advanced_options: bool, @@ -59,7 +60,6 @@ pub struct PauseTokensScreen { pub public_note: Option, status: PauseTokensStatus, - error_message: Option, // Basic references pub app_context: Arc, @@ -70,8 +70,11 @@ pub struct PauseTokensScreen { // If password-based wallet unlocking is needed selected_wallet: Option>>, wallet_unlock_popup: WalletUnlockPopup, + wallet_open_attempted: bool, // Fee result from completed operation completed_fee_result: Option, + // Banner handle for elapsed time display + refresh_banner: Option, } impl PauseTokensScreen { @@ -87,7 +90,7 @@ impl PauseTokensScreen { ) .cloned(); - let mut error_message = None; + let set_error_banner = |msg: &str| super::set_error_banner(app_context, msg); let group = match identity_token_info .token_config @@ -95,32 +98,30 @@ impl PauseTokensScreen { .authorized_to_make_change_action_takers() { AuthorizedActionTakers::NoOne => { - error_message = Some("Burning is not allowed on this token".to_string()); + set_error_banner("Pausing is not allowed on this token"); None } AuthorizedActionTakers::ContractOwner => { if identity_token_info.data_contract.contract.owner_id() != identity_token_info.identity.identity.id() { - error_message = Some( - "You are not allowed to burn this token. Only the contract owner is." - .to_string(), + set_error_banner( + "You are not allowed to pause this token. Only the contract owner is.", ); } None } AuthorizedActionTakers::Identity(identifier) => { if identifier != &identity_token_info.identity.identity.id() { - error_message = Some("You are not allowed to burn this token".to_string()); + set_error_banner("You are not allowed to pause this token"); } None } AuthorizedActionTakers::MainGroup => { match identity_token_info.token_config.main_control_group() { None => { - error_message = Some( - "Invalid contract: No main control group, though one should exist" - .to_string(), + set_error_banner( + "Invalid contract: No main control group, though one should exist", ); None } @@ -132,7 +133,7 @@ impl PauseTokensScreen { { Ok(group) => Some((group_pos, group.clone())), Err(e) => { - error_message = Some(format!("Invalid contract: {}", e)); + set_error_banner(&format!("Invalid contract: {}", e)); None } } @@ -147,7 +148,7 @@ impl PauseTokensScreen { { Ok(group) => Some((*group_pos, group.clone())), Err(e) => { - error_message = Some(format!("Invalid contract: {}", e)); + set_error_banner(&format!("Invalid contract: {}", e)); None } } @@ -170,12 +171,12 @@ impl PauseTokensScreen { }; // Attempt to get an unlocked wallet reference - let selected_wallet = get_selected_wallet( - &identity_token_info.identity, - None, - possible_key.as_ref(), - &mut error_message, - ); + let selected_wallet = + get_selected_wallet(&identity_token_info.identity, None, possible_key.as_ref()) + .unwrap_or_else(|e| { + set_error_banner(&e); + None + }); Self { identity: identity_token_info.identity.clone(), @@ -187,12 +188,13 @@ impl PauseTokensScreen { group_action_id: None, public_note: None, status: PauseTokensStatus::NotStarted, - error_message, app_context: app_context.clone(), confirmation_dialog: None, selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), + wallet_open_attempted: false, completed_fee_result: None, + refresh_banner: None, } } @@ -208,20 +210,21 @@ impl PauseTokensScreen { Some(ConfirmationStatus::Confirmed) => { self.confirmation_dialog = None; - let signing_key = match self.selected_key.clone() { - Some(key) => key, - None => { - self.error_message = Some("No signing key selected".into()); - self.status = PauseTokensStatus::ErrorMessage("No key selected".into()); - return AppAction::None; - } + // Validate signing key before transitioning to waiting state + let Some(signing_key) = + validate_signing_key(&self.app_context, self.selected_key.as_ref()) + else { + return AppAction::None; }; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.status = PauseTokensStatus::WaitingForResult(now); + self.status = PauseTokensStatus::WaitingForResult; + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Pausing tokens...", + MessageType::Info, + ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); // Grab the data contract for this token from the app context let data_contract = @@ -278,15 +281,17 @@ impl PauseTokensScreen { } impl ScreenLike for PauseTokensScreen { - fn display_message(&mut self, message: &str, message_type: MessageType) { - if let MessageType::Error = message_type { - self.status = PauseTokensStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.refresh_banner.take_and_clear(); + self.status = PauseTokensStatus::Error; } } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { if let BackendTaskSuccessResult::PausedTokens(fee_result) = backend_task_success_result { + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.status = PauseTokensStatus::Complete; } @@ -396,8 +401,11 @@ impl ScreenLike for PauseTokensScreen { } else { // Possibly handle locked wallet scenario if let Some(wallet) = &self.selected_wallet { - if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + if !self.wallet_open_attempted { + if let Err(e) = try_open_wallet_no_password(wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + } + self.wallet_open_attempted = true; } if wallet_needs_unlock(wallet) { ui.add_space(10.0); @@ -525,33 +533,11 @@ impl ScreenLike for PauseTokensScreen { ui.add_space(10.0); match &self.status { PauseTokensStatus::NotStarted => {} - PauseTokensStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed = now - start_time; - ui.label(format!("Pausing... elapsed: {}s", elapsed)); + PauseTokensStatus::WaitingForResult => { + // Elapsed display is handled by the global MessageBanner } - PauseTokensStatus::ErrorMessage(msg) => { - let error_color = DashColors::ERROR; - let msg = msg.clone(); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new(format!("Error: {}", msg)).color(error_color), - ); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.status = PauseTokensStatus::NotStarted; - } - }); - }); + PauseTokensStatus::Error => { + // Error display is handled by the global MessageBanner } PauseTokensStatus::Complete => {} } diff --git a/src/ui/tokens/resume_tokens_screen.rs b/src/ui/tokens/resume_tokens_screen.rs index b99686160..bce6badfa 100644 --- a/src/ui/tokens/resume_tokens_screen.rs +++ b/src/ui/tokens/resume_tokens_screen.rs @@ -15,11 +15,13 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::helpers::{TransactionType, add_key_chooser, render_group_action_text}; use crate::ui::identities::get_selected_wallet; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; use crate::ui::theme::DashColors; +use crate::ui::tokens::validate_signing_key; use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dpp::data_contract::GroupContractPosition; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -36,19 +38,18 @@ use eframe::egui::{self, Color32, Context, Frame, Margin, Ui}; use egui::RichText; use std::collections::HashSet; use std::sync::{Arc, RwLock}; -use std::time::{SystemTime, UNIX_EPOCH}; /// States for the resume flow #[derive(PartialEq)] pub enum ResumeTokensStatus { NotStarted, - WaitingForResult(u64), - ErrorMessage(String), + WaitingForResult, + Error, Complete, } pub struct ResumeTokensScreen { - pub identity: QualifiedIdentity, + identity: QualifiedIdentity, pub identity_token_info: IdentityTokenInfo, selected_key: Option, show_advanced_options: bool, @@ -58,7 +59,6 @@ pub struct ResumeTokensScreen { pub public_note: Option, status: ResumeTokensStatus, - error_message: Option, // Basic references pub app_context: Arc, @@ -69,8 +69,11 @@ pub struct ResumeTokensScreen { // If password-based wallet unlocking is needed selected_wallet: Option>>, wallet_unlock_popup: WalletUnlockPopup, + wallet_open_attempted: bool, // Fee result from completed operation completed_fee_result: Option, + // Banner handle for elapsed time display + refresh_banner: Option, } impl ResumeTokensScreen { @@ -86,7 +89,7 @@ impl ResumeTokensScreen { ) .cloned(); - let mut error_message = None; + let set_error_banner = |msg: &str| super::set_error_banner(app_context, msg); let group = match identity_token_info .token_config @@ -94,32 +97,30 @@ impl ResumeTokensScreen { .authorized_to_make_change_action_takers() { AuthorizedActionTakers::NoOne => { - error_message = Some("Burning is not allowed on this token".to_string()); + set_error_banner("Resuming is not allowed on this token"); None } AuthorizedActionTakers::ContractOwner => { if identity_token_info.data_contract.contract.owner_id() != identity_token_info.identity.identity.id() { - error_message = Some( - "You are not allowed to burn this token. Only the contract owner is." - .to_string(), + set_error_banner( + "You are not allowed to resume this token. Only the contract owner is.", ); } None } AuthorizedActionTakers::Identity(identifier) => { if identifier != &identity_token_info.identity.identity.id() { - error_message = Some("You are not allowed to burn this token".to_string()); + set_error_banner("You are not allowed to resume this token"); } None } AuthorizedActionTakers::MainGroup => { match identity_token_info.token_config.main_control_group() { None => { - error_message = Some( - "Invalid contract: No main control group, though one should exist" - .to_string(), + set_error_banner( + "Invalid contract: No main control group, though one should exist", ); None } @@ -131,7 +132,7 @@ impl ResumeTokensScreen { { Ok(group) => Some((group_pos, group.clone())), Err(e) => { - error_message = Some(format!("Invalid contract: {}", e)); + set_error_banner(&format!("Invalid contract: {}", e)); None } } @@ -146,7 +147,7 @@ impl ResumeTokensScreen { { Ok(group) => Some((*group_pos, group.clone())), Err(e) => { - error_message = Some(format!("Invalid contract: {}", e)); + set_error_banner(&format!("Invalid contract: {}", e)); None } } @@ -169,12 +170,12 @@ impl ResumeTokensScreen { }; // Attempt to get an unlocked wallet reference - let selected_wallet = get_selected_wallet( - &identity_token_info.identity, - None, - possible_key.as_ref(), - &mut error_message, - ); + let selected_wallet = + get_selected_wallet(&identity_token_info.identity, None, possible_key.as_ref()) + .unwrap_or_else(|e| { + set_error_banner(&e); + None + }); Self { identity: identity_token_info.identity.clone(), @@ -186,12 +187,13 @@ impl ResumeTokensScreen { group_action_id: None, public_note: None, status: ResumeTokensStatus::NotStarted, - error_message, app_context: app_context.clone(), confirmation_dialog: None, selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), + wallet_open_attempted: false, completed_fee_result: None, + refresh_banner: None, } } @@ -208,20 +210,21 @@ impl ResumeTokensScreen { Some(ConfirmationStatus::Confirmed) => { self.confirmation_dialog = None; - let signing_key = match self.selected_key.clone() { - Some(key) => key, - None => { - self.error_message = Some("No signing key selected".into()); - self.status = ResumeTokensStatus::ErrorMessage("No key selected".into()); - return AppAction::None; - } + // Validate signing key before transitioning to waiting state + let Some(signing_key) = + validate_signing_key(&self.app_context, self.selected_key.as_ref()) + else { + return AppAction::None; }; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.status = ResumeTokensStatus::WaitingForResult(now); + self.status = ResumeTokensStatus::WaitingForResult; + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Resuming tokens...", + MessageType::Info, + ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); // Grab the data contract for this token from the app context let data_contract = @@ -278,15 +281,17 @@ impl ResumeTokensScreen { } impl ScreenLike for ResumeTokensScreen { - fn display_message(&mut self, message: &str, message_type: MessageType) { - if let MessageType::Error = message_type { - self.status = ResumeTokensStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.refresh_banner.take_and_clear(); + self.status = ResumeTokensStatus::Error; } } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { if let BackendTaskSuccessResult::ResumedTokens(fee_result) = backend_task_success_result { + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.status = ResumeTokensStatus::Complete; } @@ -397,8 +402,11 @@ impl ScreenLike for ResumeTokensScreen { } else { // Possibly handle locked wallet scenario if let Some(wallet) = &self.selected_wallet { - if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + if !self.wallet_open_attempted { + if let Err(e) = try_open_wallet_no_password(wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + } + self.wallet_open_attempted = true; } if wallet_needs_unlock(wallet) { ui.add_space(10.0); @@ -525,33 +533,11 @@ impl ScreenLike for ResumeTokensScreen { ui.add_space(10.0); match &self.status { ResumeTokensStatus::NotStarted => {} - ResumeTokensStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed = now - start_time; - ui.label(format!("Resuming... elapsed: {}s", elapsed)); + ResumeTokensStatus::WaitingForResult => { + // Elapsed display is handled by the global MessageBanner } - ResumeTokensStatus::ErrorMessage(msg) => { - let error_color = DashColors::ERROR; - let msg = msg.clone(); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new(format!("Error: {}", msg)).color(error_color), - ); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.status = ResumeTokensStatus::NotStarted; - } - }); - }); + ResumeTokensStatus::Error => { + // Error display is handled by the global MessageBanner } ResumeTokensStatus::Complete => {} } diff --git a/src/ui/tokens/set_token_price_screen.rs b/src/ui/tokens/set_token_price_screen.rs index 6e86365b2..b58803e18 100644 --- a/src/ui/tokens/set_token_price_screen.rs +++ b/src/ui/tokens/set_token_price_screen.rs @@ -17,11 +17,13 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::helpers::{TransactionType, add_key_chooser}; use crate::ui::identities::get_selected_wallet; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; use crate::ui::theme::DashColors; +use crate::ui::tokens::validate_signing_key; use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dpp::balances::credits::Credits; use dash_sdk::dpp::data_contract::GroupContractPosition; @@ -43,7 +45,6 @@ use egui::RichText; use egui_extras::{Column, TableBuilder}; use std::collections::HashSet; use std::sync::{Arc, RwLock}; -use std::time::{SystemTime, UNIX_EPOCH}; /// Pricing type selection #[derive(PartialEq, Clone)] @@ -75,8 +76,8 @@ impl From> for PricingType { #[derive(PartialEq)] pub enum SetTokenPriceStatus { NotStarted, - WaitingForResult(u64), // Use seconds or millis - ErrorMessage(String), + WaitingForResult, + Error, Complete, } @@ -92,16 +93,15 @@ pub struct SetTokenPriceScreen { pub token_pricing_schedule: String, /// Token pricing schedule to use; if None, we will remove the pricing schedule - pub pricing_type: PricingType, + pricing_type: PricingType, // AmountInput components for pricing - following the design pattern single_price_amount: Option, single_price_input: Option, // Tiered pricing with AmountInput components - pub tiered_prices: Vec<(Option, Option)>, // (amount_input, price_input) + tiered_prices: Vec<(Option, Option)>, // (amount_input, price_input) status: SetTokenPriceStatus, - error_message: Option, /// Basic references pub app_context: Arc, @@ -113,8 +113,11 @@ pub struct SetTokenPriceScreen { // If needed for password-based wallet unlocking: selected_wallet: Option>>, wallet_unlock_popup: WalletUnlockPopup, + wallet_open_attempted: bool, // Fee result from completed operation completed_fee_result: Option, + // Banner handle for elapsed time display + refresh_banner: Option, } /// 1 Dash = 100,000,000,000 credits @@ -170,7 +173,7 @@ impl SetTokenPriceScreen { false, ); - let mut error_message = None; + let set_error_banner = |msg: &str| super::set_error_banner(app_context, msg); let group = match identity_token_info .token_config @@ -179,34 +182,30 @@ impl SetTokenPriceScreen { .authorized_to_make_change_action_takers() { AuthorizedActionTakers::NoOne => { - error_message = - Some("Setting token price is not allowed on this token".to_string()); + set_error_banner("Setting token price is not allowed on this token"); None } AuthorizedActionTakers::ContractOwner => { if identity_token_info.data_contract.contract.owner_id() != identity_token_info.identity.identity.id() { - error_message = Some( - "You are not allowed to set token price on this token. Only the contract owner is." - .to_string(), + set_error_banner( + "You are not allowed to set token price on this token. Only the contract owner is.", ); } None } AuthorizedActionTakers::Identity(identifier) => { if identifier != &identity_token_info.identity.identity.id() { - error_message = - Some("You are not allowed to set token price on this token".to_string()); + set_error_banner("You are not allowed to set token price on this token"); } None } AuthorizedActionTakers::MainGroup => { match identity_token_info.token_config.main_control_group() { None => { - error_message = Some( - "Invalid contract: No main control group, though one should exist" - .to_string(), + set_error_banner( + "Invalid contract: No main control group, though one should exist", ); None } @@ -218,7 +217,7 @@ impl SetTokenPriceScreen { { Ok(group) => Some((group_pos, group.clone())), Err(e) => { - error_message = Some(format!("Invalid contract: {}", e)); + set_error_banner(&format!("Invalid contract: {}", e)); None } } @@ -233,7 +232,7 @@ impl SetTokenPriceScreen { { Ok(group) => Some((*group_pos, group.clone())), Err(e) => { - error_message = Some(format!("Invalid contract: {}", e)); + set_error_banner(&format!("Invalid contract: {}", e)); None } } @@ -256,12 +255,13 @@ impl SetTokenPriceScreen { }; // Attempt to get an unlocked wallet reference - let selected_wallet = get_selected_wallet( - &identity_token_info.identity, - None, - possible_key, - &mut error_message, - ); + let selected_wallet = + get_selected_wallet(&identity_token_info.identity, None, possible_key).unwrap_or_else( + |e| { + set_error_banner(&e); + None + }, + ); Self { identity_token_info: identity_token_info.clone(), @@ -277,13 +277,14 @@ impl SetTokenPriceScreen { single_price_input: None, tiered_prices: vec![(None, None)], status: SetTokenPriceStatus::NotStarted, - error_message: None, app_context: app_context.clone(), show_confirmation_popup: false, confirmation_dialog: None, selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), + wallet_open_attempted: false, completed_fee_result: None, + refresh_banner: None, } } @@ -725,20 +726,29 @@ impl SetTokenPriceScreen { } }; + // Validate signing key before transitioning to waiting state + let Some(signing_key) = validate_signing_key(&self.app_context, self.selected_key.as_ref()) + else { + return AppAction::None; + }; + // Set waiting state - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.status = SetTokenPriceStatus::WaitingForResult(now); + self.status = SetTokenPriceStatus::WaitingForResult; + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Setting token price...", + MessageType::Info, + ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); // Prepare group info - let group_info = if self.group_action_id.is_some() { + let group_info = if let Some(action_id) = self.group_action_id { self.group.as_ref().map(|(pos, _)| { GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( GroupStateTransitionInfo { group_contract_position: *pos, - action_id: self.group_action_id.unwrap(), + action_id, action_is_proposer: false, }, ) @@ -755,7 +765,7 @@ impl SetTokenPriceScreen { identity: self.identity_token_info.identity.clone(), data_contract: Arc::new(self.identity_token_info.data_contract.contract.clone()), token_position: self.identity_token_info.token_position, - signing_key: self.selected_key.clone().expect("Expected a key"), + signing_key, public_note: if self.group_action_id.is_some() { None } else { @@ -776,8 +786,8 @@ impl SetTokenPriceScreen { /// Set error state with the given message fn set_error_state(&mut self, error: String) { - self.error_message = Some(error.clone()); - self.status = SetTokenPriceStatus::ErrorMessage(error); + self.status = SetTokenPriceStatus::Error; + MessageBanner::set_global(self.app_context.egui_ctx(), &error, MessageType::Error); } /// Renders a confirm popup with the final "Are you sure?" step @@ -818,15 +828,17 @@ impl SetTokenPriceScreen { } impl ScreenLike for SetTokenPriceScreen { - fn display_message(&mut self, message: &str, message_type: MessageType) { - if let MessageType::Error = message_type { - self.status = SetTokenPriceStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.refresh_banner.take_and_clear(); + self.status = SetTokenPriceStatus::Error; } } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { if let BackendTaskSuccessResult::SetTokenPrice(fee_result) = backend_task_success_result { + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.status = SetTokenPriceStatus::Complete; } @@ -951,8 +963,11 @@ impl ScreenLike for SetTokenPriceScreen { } else { // Possibly handle locked wallet scenario (similar to TransferTokens) if let Some(wallet) = &self.selected_wallet { - if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + if !self.wallet_open_attempted { + if let Err(e) = try_open_wallet_no_password(wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + } + self.wallet_open_attempted = true; } if wallet_needs_unlock(wallet) { ui.add_space(10.0); @@ -1046,8 +1061,11 @@ impl ScreenLike for SetTokenPriceScreen { .members() .get(&self.identity_token_info.identity.identity.id()); if your_power.is_none() { - self.error_message = - Some("Only group members can set price on this token".to_string()); + MessageBanner::set_global( + ui.ctx(), + "Only group members can set price on this token", + MessageType::Error, + ); } ui.heading("This is a group action, it is not immediate."); ui.label(format!( @@ -1109,7 +1127,7 @@ impl ScreenLike for SetTokenPriceScreen { // Set price button let validation_result = self.validate_pricing_configuration(); - let button_active = validation_result.is_ok() && !matches!(self.status, SetTokenPriceStatus::WaitingForResult(_)); + let button_active = validation_result.is_ok() && !matches!(self.status, SetTokenPriceStatus::WaitingForResult); let button_color = if validation_result.is_ok() { DashColors::ACTION_BUTTON_BLUE @@ -1140,31 +1158,11 @@ impl ScreenLike for SetTokenPriceScreen { SetTokenPriceStatus::NotStarted => { // no-op } - SetTokenPriceStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed = now - start_time; - ui.label(format!("Setting price... elapsed: {} seconds", elapsed)); + SetTokenPriceStatus::WaitingForResult => { + // Elapsed display is handled by the global MessageBanner } - SetTokenPriceStatus::ErrorMessage(msg) => { - let error_color = DashColors::ERROR; - let msg = msg.clone(); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new(format!("Error: {}", msg)).color(error_color)); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.status = SetTokenPriceStatus::NotStarted; - } - }); - }); + SetTokenPriceStatus::Error => { + // Error display is handled by the global MessageBanner } SetTokenPriceStatus::Complete => { // handled above diff --git a/src/ui/tokens/tokens_screen/contract_details.rs b/src/ui/tokens/tokens_screen/contract_details.rs index 9f96d00dc..f315e87d0 100644 --- a/src/ui/tokens/tokens_screen/contract_details.rs +++ b/src/ui/tokens/tokens_screen/contract_details.rs @@ -1,3 +1,5 @@ +use crate::ui::MessageType; +use crate::ui::components::MessageBanner; use crate::ui::tokens::tokens_screen::TokensScreen; use crate::{app::AppAction, ui::theme::DashColors}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; @@ -85,7 +87,7 @@ impl TokensScreen { action |= internal_action; } Err(e) => { - self.token_creator_error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } } } @@ -96,7 +98,11 @@ impl TokensScreen { self.json_popup_text = schema; } Err(e) => { - self.token_creator_error_message = Some(e.to_string()); + MessageBanner::set_global( + ui.ctx(), + e.to_string(), + MessageType::Error, + ); } } } diff --git a/src/ui/tokens/tokens_screen/keyword_search.rs b/src/ui/tokens/tokens_screen/keyword_search.rs index 1879675f1..59e402e5a 100644 --- a/src/ui/tokens/tokens_screen/keyword_search.rs +++ b/src/ui/tokens/tokens_screen/keyword_search.rs @@ -2,14 +2,15 @@ use crate::app::AppAction; use crate::backend_task::BackendTask; use crate::backend_task::contract::ContractTask; use crate::backend_task::tokens::TokenTask; +use crate::ui::MessageType; +use crate::ui::components::{MessageBanner, OptionBannerExt}; use crate::ui::theme::DashColors; use crate::ui::tokens::tokens_screen::{ ContractDescriptionInfo, ContractSearchStatus, TokensScreen, }; -use chrono::Utc; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use eframe::emath::Align; -use egui::{Frame, Margin, RichText, Ui}; +use egui::{RichText, Ui}; use egui_extras::{Column, TableBuilder}; const KEYWORD_SEARCH_INFO_TEXT: &str = "Keyword Search allows you to find tokens by searching their associated keywords.\n\n\ @@ -59,8 +60,15 @@ impl TokensScreen { if go_clicked || enter_pressed { // Clear old results, set status self.search_results.lock().unwrap().clear(); - let now = Utc::now().timestamp() as u64; - self.contract_search_status = ContractSearchStatus::WaitingForResult(now); + self.contract_search_status = ContractSearchStatus::WaitingForResult; + self.operation_banner.take_and_clear(); + let handle = MessageBanner::set_global( + ui.ctx(), + "Searching contracts...", + MessageType::Info, + ); + handle.with_elapsed(); + self.operation_banner = Some(handle); self.search_current_page = 1; self.next_cursors.clear(); self.previous_cursors.clear(); @@ -103,13 +111,8 @@ impl TokensScreen { ContractSearchStatus::NotStarted => { // Nothing } - ContractSearchStatus::WaitingForResult(start_time) => { - let now = Utc::now().timestamp() as u64; - let elapsed = now - start_time; - ui.horizontal(|ui| { - ui.label(format!("Searching... {} seconds", elapsed)); - ui.add(egui::widgets::Spinner::default().color(DashColors::DASH_BLUE)); - }); + ContractSearchStatus::WaitingForResult => { + // Elapsed display is handled by the global MessageBanner } ContractSearchStatus::Complete => { // Show the results @@ -138,23 +141,8 @@ impl TokensScreen { } } } - ContractSearchStatus::ErrorMessage(e) => { - let error_color = DashColors::error_color(ui.visuals().dark_mode); - let msg = e.clone(); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new(format!("Error: {}", msg)).color(error_color)); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.contract_search_status = ContractSearchStatus::NotStarted; - } - }); - }); + ContractSearchStatus::Error => { + // Error message is displayed by the global MessageBanner } } diff --git a/src/ui/tokens/tokens_screen/mod.rs b/src/ui/tokens/tokens_screen/mod.rs index 4c5f737a9..0330998ce 100644 --- a/src/ui/tokens/tokens_screen/mod.rs +++ b/src/ui/tokens/tokens_screen/mod.rs @@ -60,10 +60,12 @@ use crate::context::AppContext; use crate::model::amount::Amount; use crate::model::qualified_identity::{IdentityType, QualifiedIdentity}; use crate::model::wallet::Wallet; +use crate::ui::components::MessageBanner; use crate::ui::components::amount_input::AmountInput; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::info_popup::InfoPopup; use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::message_banner::{BannerHandle, OptionBannerExt}; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; use crate::ui::components::top_panel::add_top_panel; @@ -184,26 +186,26 @@ impl TokensSubscreen { #[derive(PartialEq)] pub enum RefreshingStatus { - Refreshing(u64), + Refreshing, NotRefreshing, } -/// Represents the status of the user’s search +/// Represents the status of the user's search #[derive(PartialEq, Eq, Clone)] pub enum ContractSearchStatus { NotStarted, - WaitingForResult(u64), + WaitingForResult, Complete, - ErrorMessage(String), + Error, } #[derive(Debug, PartialEq, Default)] pub enum TokenCreatorStatus { #[default] NotStarted, - WaitingForResult(u64), + WaitingForResult, Complete, - ErrorMessage(String), + Error, } /// Sorting columns @@ -1167,7 +1169,6 @@ pub struct TokensScreen { Option, >, pricing_loading_state: IndexMap, - backend_message: Option<(String, MessageType, DateTime)>, pending_backend_task: Option, refreshing_status: RefreshingStatus, should_reset_collapsing_states: bool, @@ -1240,6 +1241,7 @@ pub struct TokensScreen { selected_identity: Option, selected_key: Option, selected_wallet: Option>>, + wallet_open_attempted: bool, wallet_unlock_popup: WalletUnlockPopup, token_names_input: Vec<(String, String, TokenNameLanguage, TokenSearchable)>, contract_keywords_input: String, @@ -1255,7 +1257,6 @@ pub struct TokensScreen { show_token_creator_confirmation_popup: bool, token_creator_confirmation_dialog: Option, token_creator_status: TokenCreatorStatus, - token_creator_error_message: Option, show_advanced_keeps_history: bool, token_advanced_keeps_history: TokenKeepsHistoryRulesV0, groups_ui: Vec, @@ -1385,6 +1386,9 @@ pub struct TokensScreen { adding_token_start_time: Option>, adding_token_name: Option, + // Banner handle for elapsed-time progress display + operation_banner: Option, + // Document Schemas document_schemas_input: String, parsed_document_schemas: Option>, @@ -1564,7 +1568,6 @@ impl TokensScreen { next_cursors: vec![], previous_cursors: vec![], search_results: Arc::new(Mutex::new(Vec::new())), - backend_message: None, sort_column: SortColumn::OwnerIdentityAlias, sort_order: SortOrder::Ascending, use_custom_order: false, @@ -1593,11 +1596,11 @@ impl TokensScreen { selected_identity: None, selected_key: None, selected_wallet: None, + wallet_open_attempted: false, wallet_unlock_popup: WalletUnlockPopup::new(), show_token_creator_confirmation_popup: false, token_creator_confirmation_dialog: None, token_creator_status: TokenCreatorStatus::NotStarted, - token_creator_error_message: None, token_names_input: vec![( String::new(), String::new(), @@ -1762,6 +1765,9 @@ impl TokensScreen { adding_token_start_time: None, adding_token_name: None, + // Banner handle for elapsed-time progress display + operation_banner: None, + // Document Schemas document_schemas_input: String::new(), parsed_document_schemas: None, @@ -1844,19 +1850,7 @@ impl TokensScreen { // Message handling // ───────────────────────────────────────────────────────────────── - fn dismiss_message(&mut self) { - self.backend_message = None; - } - - fn check_error_expiration(&mut self) { - if let Some((_, _, timestamp)) = &self.backend_message { - let now = Utc::now(); - let elapsed = now.signed_duration_since(*timestamp); - if elapsed.num_seconds() >= 10 { - self.dismiss_message(); - } - } - } + // Message display is handled by the global MessageBanner fn history_row(&mut self, ui: &mut Ui) { // --- 1. pull or create the rules object -------------------------------- @@ -2249,9 +2243,10 @@ impl TokensScreen { let id_res = Identifier::from_string(id, Encoding::Base58); TokenDistributionRecipient::Identity(id_res.unwrap_or_default()) } else { - self.token_creator_error_message = Some( - "Invalid base58 identifier for perpetual distribution recipient" - .to_string(), + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Invalid base58 identifier for perpetual distribution recipient", + MessageType::Error, ); return Err( "Invalid base58 identifier for perpetual distribution recipient" @@ -2285,7 +2280,11 @@ impl TokensScreen { match self.parse_pre_programmed_distributions() { Ok(distributions) => distributions, Err(err) => { - self.token_creator_error_message = Some(err.clone()); + MessageBanner::set_global( + self.app_context.egui_ctx(), + &err, + MessageType::Error, + ); return Err(err.to_string()); } }; @@ -2484,7 +2483,6 @@ impl TokensScreen { self.minting_allow_choosing_destination_rules = ChangeControlRulesUI::default(); self.show_token_creator_confirmation_popup = false; - self.token_creator_error_message = None; // Reset document schemas self.document_schemas_input = String::new(); @@ -2495,18 +2493,25 @@ impl TokensScreen { fn add_token_to_tracked_tokens(&mut self, token_info: TokenInfo) -> Result { // Check if token is already added if self.all_known_tokens.contains_key(&token_info.token_id) { - self.backend_message = Some(( - "Token already in My Tokens".to_string(), + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Token already in My Tokens", MessageType::Error, - Utc::now(), - )); + ); return Ok(AppAction::None); } - // Set adding status with timestamp for elapsed time display + // Set adding status self.adding_token_start_time = Some(Utc::now()); self.adding_token_name = Some(token_info.token_name.clone()); - self.backend_message = Some(("Adding token...".to_string(), MessageType::Info, Utc::now())); + self.operation_banner.take_and_clear(); + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Adding token...", + MessageType::Info, + ); + handle.with_elapsed(); + self.operation_banner = Some(handle); // Always save the token locally and refresh balances // The contract will be fetched automatically when needed @@ -2526,10 +2531,17 @@ impl TokensScreen { // If we have a next cursor: if let Some(next_cursor) = self.next_cursors.last().cloned() { // set status - let now = Utc::now().timestamp() as u64; - self.contract_search_status = ContractSearchStatus::WaitingForResult(now); + self.contract_search_status = ContractSearchStatus::WaitingForResult; + self.operation_banner.take_and_clear(); + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Searching contracts...", + MessageType::Info, + ); + handle.with_elapsed(); + self.operation_banner = Some(handle); - // push the current one onto “previous” so we can go back + // push the current one onto "previous" so we can go back // if the user is on page N, and we have a nextCursor in next_cursors[N - 1] or so self.previous_cursors.push(next_cursor.clone()); @@ -2549,10 +2561,17 @@ impl TokensScreen { if self.search_current_page > 1 { // Move to (page - 1) self.search_current_page -= 1; - let now = Utc::now().timestamp() as u64; - self.contract_search_status = ContractSearchStatus::WaitingForResult(now); + self.contract_search_status = ContractSearchStatus::WaitingForResult; + self.operation_banner.take_and_clear(); + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Searching contracts...", + MessageType::Info, + ); + handle.with_elapsed(); + self.operation_banner = Some(handle); - // The “last” previous_cursors item is the new page’s state + // The "last" previous_cursors item is the new page's state if let Some(prev_cursor) = self.previous_cursors.pop() { // Possibly pop from next_cursors if we want to re-insert it later // self.next_cursors.truncate(self.search_current_page - 1); @@ -2601,11 +2620,11 @@ impl TokensScreen { .app_context .remove_token_balance(token_to_remove.token_id, token_to_remove.identity_id) { - self.backend_message = Some(( + MessageBanner::set_global( + self.app_context.egui_ctx(), format!("Error removing token balance: {}", e), MessageType::Error, - Utc::now(), - )); + ); } else { self.refresh(); } @@ -2663,11 +2682,11 @@ impl TokensScreen { .db .remove_token(&token_to_remove, &self.app_context) { - self.backend_message = Some(( + MessageBanner::set_global( + self.app_context.egui_ctx(), format!("Error removing token balance: {}", e), MessageType::Error, - Utc::now(), - )); + ); } else { self.refresh(); } @@ -2801,8 +2820,6 @@ impl ScreenLike for TokensScreen { fn ui(&mut self, ctx: &Context) -> AppAction { let mut action = AppAction::None; - self.check_error_expiration(); - // Build top-right buttons let right_buttons = match self.tokens_subscreen { TokensSubscreen::MyTokens => vec![ @@ -2926,38 +2943,7 @@ impl ScreenLike for TokensScreen { } } - // Show either refreshing indicator or message, but not both - if let RefreshingStatus::Refreshing(start_time) = self.refreshing_status { - ui.add_space(25.0); // Space above - let now = Utc::now().timestamp() as u64; - let elapsed = now - start_time; - ui.horizontal(|ui| { - ui.add_space(10.0); - ui.label(format!("Refreshing... Time so far: {}", elapsed)); - ui.add(egui::widgets::Spinner::default().color(DashColors::DASH_BLUE)); - }); - ui.add_space(2.0); // Space below - } else if let Some((msg, msg_type, timestamp)) = self.backend_message.clone() { - ui.add_space(25.0); // Same space as refreshing indicator - let dark_mode = ui.ctx().style().visuals.dark_mode; - let color = match msg_type { - MessageType::Error => Color32::DARK_RED, - MessageType::Warning => DashColors::warning_color(dark_mode), - MessageType::Info => DashColors::text_primary(dark_mode), - MessageType::Success => Color32::DARK_GREEN, - }; - ui.horizontal(|ui| { - // Calculate remaining seconds - let now = Utc::now(); - let elapsed = now.signed_duration_since(timestamp); - let remaining = (10 - elapsed.num_seconds()).max(0); - - // Add the message with auto-dismiss countdown - let full_msg = format!("{} ({}s)", msg, remaining); - ui.label(egui::RichText::new(full_msg).color(color)); - }); - ui.add_space(2.0); // Same space below as refreshing indicator - } + // Elapsed display for refreshing is handled by the global MessageBanner if self.confirm_remove_identity_token_balance_popup { self.show_remove_identity_token_balance_popup(ui); @@ -2984,9 +2970,12 @@ impl ScreenLike for TokensScreen { AppAction::BackendTask(BackendTask::TokenTask(ref token_task)) if matches!(token_task.as_ref(), TokenTask::QueryMyTokenBalances) => { - self.refreshing_status = - RefreshingStatus::Refreshing(Utc::now().timestamp() as u64); - self.backend_message = None; // Clear any existing message + self.refreshing_status = RefreshingStatus::Refreshing; + self.operation_banner.take_and_clear(); + let handle = + MessageBanner::set_global(ctx, "Refreshing tokens...", MessageType::Info); + handle.with_elapsed(); + self.operation_banner = Some(handle); } AppAction::SetMainScreenThenGoToMainScreen(_) => { self.refreshing_status = RefreshingStatus::NotRefreshing; @@ -3037,20 +3026,26 @@ impl ScreenLike for TokensScreen { } fn display_message(&mut self, msg: &str, msg_type: MessageType) { - // Reset contract details loading on any error - if msg_type == MessageType::Error && self.contract_details_loading { + // Banner display is handled globally by AppState; this is only for side-effects. + + // Clear the operation banner only on Error/Warning (task failed). + if matches!(msg_type, MessageType::Error | MessageType::Warning) { + self.operation_banner.take_and_clear(); + } + + // Reset contract details loading on any error/warning + if matches!(msg_type, MessageType::Error | MessageType::Warning) + && self.contract_details_loading + { self.contract_details_loading = false; } match self.tokens_subscreen { TokensSubscreen::TokenCreator => { - if msg.contains("Successfully registered token contract") { - self.token_creator_status = TokenCreatorStatus::Complete; - } else if msg.contains("Failed to register token contract") + if msg.contains("Failed to register token contract") | msg.contains("Error building contract V1") { - self.token_creator_status = TokenCreatorStatus::ErrorMessage(msg.to_string()); - self.token_creator_error_message = Some(msg.to_string()); + self.token_creator_status = TokenCreatorStatus::Error; } } TokensSubscreen::MyTokens => { @@ -3064,13 +3059,10 @@ impl ScreenLike for TokensScreen { self.adding_token_start_time = None; self.adding_token_name = None; } - self.backend_message = Some((msg.to_string(), msg_type, Utc::now())); self.refreshing_status = RefreshingStatus::NotRefreshing; - } else if msg.contains("Failed to query token pricing") { - self.backend_message = Some((msg.to_string(), MessageType::Error, Utc::now())); } else { tracing::debug!( - ?msg, + msg = msg, ?msg_type, "unsupported message received in token screen" ); @@ -3078,8 +3070,7 @@ impl ScreenLike for TokensScreen { } TokensSubscreen::SearchTokens => { if msg_type == MessageType::Error { - self.contract_search_status = - ContractSearchStatus::ErrorMessage(msg.to_string()); + self.contract_search_status = ContractSearchStatus::Error; // Clear adding status on error self.adding_token_start_time = None; self.adding_token_name = None; @@ -3087,20 +3078,18 @@ impl ScreenLike for TokensScreen { | msg.contains("Token already added") | msg.contains("Saved token to db") { - // Clear adding status and show success message + // Clear adding status (success message shown by global banner) self.adding_token_start_time = None; self.adding_token_name = None; - self.backend_message = Some(( - "Token added successfully!".to_string(), - MessageType::Success, - Utc::now(), - )); } } } } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { + // Clear any active operation banner + self.operation_banner.take_and_clear(); + match backend_task_success_result { BackendTaskSuccessResult::DescriptionsByKeyword(descriptions, next_cursor) => { let mut sr = self.search_results.lock().unwrap(); diff --git a/src/ui/tokens/tokens_screen/my_tokens.rs b/src/ui/tokens/tokens_screen/my_tokens.rs index a97dfdc47..71fefeeb3 100644 --- a/src/ui/tokens/tokens_screen/my_tokens.rs +++ b/src/ui/tokens/tokens_screen/my_tokens.rs @@ -2,6 +2,7 @@ use crate::app::AppAction; use crate::backend_task::BackendTask; use crate::backend_task::tokens::TokenTask; use crate::model::amount::Amount; +use crate::ui::components::MessageBanner; use crate::ui::theme::DashColors; use crate::ui::tokens::burn_tokens_screen::BurnTokensScreen; use crate::ui::tokens::claim_tokens_screen::ClaimTokensScreen; @@ -21,8 +22,8 @@ use crate::ui::tokens::transfer_tokens_screen::TransferTokensScreen; use crate::ui::tokens::unfreeze_tokens_screen::UnfreezeTokensScreen; use crate::ui::tokens::update_token_config::UpdateTokenConfigScreen; use crate::ui::tokens::view_token_claims_screen::ViewTokenClaimsScreen; -use crate::ui::{Screen, ScreenType}; -use chrono::{Local, Utc}; +use crate::ui::{MessageType, Screen, ScreenType}; +use chrono::Local; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; use dash_sdk::dpp::data_contract::associated_token::token_configuration_convention::accessors::v0::TokenConfigurationConventionV0Getters; @@ -163,7 +164,9 @@ impl TokensScreen { // Otherwise, show the list of all tokens match self.render_token_list(ui) { Ok(list_action) => action |= list_action, - Err(e) => self.token_creator_error_message = Some(e), + Err(e) => { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + } } } } @@ -274,11 +277,17 @@ impl TokensScreen { .min_size(egui::vec2(150.0, 36.0)); if ui.add(button).clicked() { - if let RefreshingStatus::Refreshing(_) = self.refreshing_status { + if let RefreshingStatus::Refreshing = self.refreshing_status { app_action = AppAction::None; } else { - self.refreshing_status = - RefreshingStatus::Refreshing(Utc::now().timestamp() as u64); + self.refreshing_status = RefreshingStatus::Refreshing; + let handle = MessageBanner::set_global( + ui.ctx(), + "Refreshing tokens...", + MessageType::Info, + ); + handle.with_elapsed(); + self.operation_banner = Some(handle); app_action = AppAction::Refresh; } } @@ -469,7 +478,14 @@ impl TokensScreen { identity_id: itb.identity_id, token_id: itb.token_id, }))); - self.refreshing_status = RefreshingStatus::Refreshing(Utc::now().timestamp() as u64); + self.refreshing_status = RefreshingStatus::Refreshing; + let handle = MessageBanner::set_global( + ui.ctx(), + "Estimating rewards...", + MessageType::Info, + ); + handle.with_elapsed(); + self.operation_banner = Some(handle); } }) }); @@ -482,7 +498,14 @@ impl TokensScreen { identity_id: itb.identity_id, token_id: itb.token_id, }))); - self.refreshing_status = RefreshingStatus::Refreshing(Utc::now().timestamp() as u64); + self.refreshing_status = RefreshingStatus::Refreshing; + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Estimating rewards...", + MessageType::Info, + ); + handle.with_elapsed(); + self.operation_banner = Some(handle); } } @@ -647,12 +670,18 @@ impl TokensScreen { ui.close_kind(egui::UiKind::Menu); } Ok(None) => { - self.token_creator_error_message = - Some("Token contract not found".to_string()); + MessageBanner::set_global( + ui.ctx(), + "Token contract not found", + MessageType::Error, + ); } Err(e) => { - self.token_creator_error_message = - Some(format!("Error fetching token contract: {e}")); + MessageBanner::set_global( + ui.ctx(), + format!("Error fetching token contract: {e}"), + MessageType::Error, + ); } } } @@ -664,16 +693,11 @@ impl TokensScreen { match IdentityTokenInfo::try_from_identity_token_maybe_balance_with_actions_with_lookup(itb, &self.app_context) { Ok(info) => { action = AppAction::AddScreen( - Screen::MintTokensScreen( - MintTokensScreen::new( - info, - &self.app_context, - ), - ), + Screen::MintTokensScreen(MintTokensScreen::new(info, &self.app_context)), ); } Err(e) => { - self.token_creator_error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } }; @@ -695,7 +719,7 @@ impl TokensScreen { ); } Err(e) => { - self.token_creator_error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } }; ui.close_kind(egui::UiKind::Menu); @@ -707,16 +731,11 @@ impl TokensScreen { match IdentityTokenInfo::try_from_identity_token_maybe_balance_with_actions_with_lookup(itb, &self.app_context) { Ok(info) => { action = AppAction::AddScreen( - Screen::FreezeTokensScreen( - FreezeTokensScreen::new( - info, - &self.app_context, - ), - ), + Screen::FreezeTokensScreen(FreezeTokensScreen::new(info, &self.app_context)), ); } Err(e) => { - self.token_creator_error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } }; ui.close_kind(egui::UiKind::Menu); @@ -728,16 +747,11 @@ impl TokensScreen { match IdentityTokenInfo::try_from_identity_token_maybe_balance_with_actions_with_lookup(itb, &self.app_context) { Ok(info) => { action = AppAction::AddScreen( - Screen::DestroyFrozenFundsScreen( - DestroyFrozenFundsScreen::new( - info, - &self.app_context, - ), - ), + Screen::DestroyFrozenFundsScreen(DestroyFrozenFundsScreen::new(info, &self.app_context)), ); } Err(e) => { - self.token_creator_error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } }; ui.close_kind(egui::UiKind::Menu); @@ -749,16 +763,11 @@ impl TokensScreen { match IdentityTokenInfo::try_from_identity_token_maybe_balance_with_actions_with_lookup(itb, &self.app_context) { Ok(info) => { action = AppAction::AddScreen( - Screen::UnfreezeTokensScreen( - UnfreezeTokensScreen::new( - info, - &self.app_context, - ), - ), + Screen::UnfreezeTokensScreen(UnfreezeTokensScreen::new(info, &self.app_context)), ); } Err(e) => { - self.token_creator_error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } }; ui.close_kind(egui::UiKind::Menu); @@ -780,7 +789,7 @@ impl TokensScreen { ); } Err(e) => { - self.token_creator_error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } }; ui.close_kind(egui::UiKind::Menu); @@ -802,7 +811,7 @@ impl TokensScreen { ); } Err(e) => { - self.token_creator_error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } }; ui.close_kind(egui::UiKind::Menu); @@ -833,7 +842,7 @@ impl TokensScreen { ); } Err(e) => { - self.token_creator_error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } }; ui.close_kind(egui::UiKind::Menu); @@ -891,7 +900,7 @@ impl TokensScreen { ); } Err(e) => { - self.token_creator_error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } }; ui.close_kind(egui::UiKind::Menu); @@ -930,7 +939,7 @@ impl TokensScreen { ); } Err(e) => { - self.token_creator_error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } }; diff --git a/src/ui/tokens/tokens_screen/structs.rs b/src/ui/tokens/tokens_screen/structs.rs index 3aae61c78..a5be18eff 100644 --- a/src/ui/tokens/tokens_screen/structs.rs +++ b/src/ui/tokens/tokens_screen/structs.rs @@ -441,7 +441,7 @@ pub fn get_available_token_actions_for_identity( let identity_id = identity.identity.id(); let solo_action_taker = ActionTaker::SingleIdentity(identity_id); - let can_transfer = known_balance.is_some() && known_balance.unwrap() > 0; + let can_transfer = known_balance.is_some_and(|b| b > 0); let is_authorized = |takers: &AuthorizedActionTakers| { takers.allowed_for_action_taker( diff --git a/src/ui/tokens/tokens_screen/token_creator.rs b/src/ui/tokens/tokens_screen/token_creator.rs index b98924f7a..f9ed0cacf 100644 --- a/src/ui/tokens/tokens_screen/token_creator.rs +++ b/src/ui/tokens/tokens_screen/token_creator.rs @@ -1,5 +1,4 @@ use std::collections::{BTreeMap, HashSet}; -use chrono::Utc; use dash_sdk::dpp::data_contract::associated_token::token_configuration::v0::{TokenConfigurationPreset, TokenConfigurationPresetFeatures}; use dash_sdk::dpp::data_contract::associated_token::token_configuration::v0::TokenConfigurationPresetFeatures::{MostRestrictive, WithAllAdvancedActions, WithExtremeActions, WithMintingAndBurningActions, WithOnlyEmergencyAction}; use dash_sdk::dpp::data_contract::associated_token::token_distribution_rules::TokenDistributionRules; @@ -21,6 +20,8 @@ use crate::ui::components::styled::{StyledCheckbox}; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::Component; use crate::ui::components::identity_selector::IdentitySelector; +use crate::ui::components::MessageBanner; +use crate::ui::MessageType; use crate::ui::helpers::{add_identity_key_chooser, TransactionType}; use dash_sdk::dpp::identity::{Purpose, SecurityLevel}; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; @@ -283,12 +284,12 @@ impl TokensScreen { if crate::ui::helpers::info_icon_button(ui, "An optional description explaining what your token is for.\n\n\ This helps users understand the purpose of your token.\n\n\ - Maximum 100 characters.").clicked() { + 3–100 characters, or leave blank.").clicked() { self.show_pop_up_info = Some( "Description\n\n\ An optional description explaining what your token is for.\n\n\ This helps users understand the purpose of your token.\n\n\ - Maximum 100 characters.".to_string() + 3–100 characters, or leave blank.".to_string() ); } }); @@ -426,11 +427,10 @@ impl TokensScreen { match self.parse_token_build_args() { Ok(args) => { self.cached_build_args = Some(args); - self.token_creator_error_message = None; self.show_token_creator_confirmation_popup = true; } Err(err_msg) => { - self.token_creator_error_message = Some(err_msg); + MessageBanner::set_global(context, &err_msg, MessageType::Error); } } } @@ -556,7 +556,7 @@ impl TokensScreen { ui.end_row(); // Row 5: Token Description - ui.label("Token Description (max 100 chars):"); + ui.label("Token Description (3–100 chars):"); ui.text_edit_singleline(&mut self.token_description_input); ui.end_row(); }); @@ -869,11 +869,10 @@ impl TokensScreen { // If success, show the "confirmation popup" // Or skip the popup entirely and dispatch tasks right now self.cached_build_args = Some(args); - self.token_creator_error_message = None; self.show_token_creator_confirmation_popup = true; }, Err(err) => { - self.token_creator_error_message = Some(err); + MessageBanner::set_global(context, &err, MessageType::Error); } } } @@ -922,7 +921,7 @@ impl TokensScreen { ) { Ok(dc) => dc, Err(e) => { - self.token_creator_error_message = Some(format!("Error building contract V1: {e}")); + MessageBanner::set_global(context, format!("Error building contract V1: {e}"), MessageType::Error); return; } }; @@ -932,7 +931,7 @@ impl TokensScreen { self.json_popup_text = serde_json::to_string_pretty(&data_contract_json).expect("Expected to serialize json"); }, Err(err_msg) => { - self.token_creator_error_message = Some(err_msg); + MessageBanner::set_global(context, &err_msg, MessageType::Error); }, } } @@ -954,40 +953,7 @@ impl TokensScreen { self.render_data_contract_json_popup(ui); } - // 8) If we are waiting, show spinner / time elapsed - if let TokenCreatorStatus::WaitingForResult(start_time) = self.token_creator_status { - let now = Utc::now().timestamp() as u64; - let elapsed = now - start_time; - ui.add_space(10.0); - ui.horizontal(|ui| { - ui.label(format!( - "Registering token contract... elapsed {}s", - elapsed - )); - ui.add(egui::widgets::Spinner::default().color(DashColors::DASH_BLUE)); - }); - } - - // Show an error if we have one - if let Some(err_msg) = self.token_creator_error_message.clone() { - ui.add_space(10.0); - let error_color = DashColors::ERROR; - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new(format!("Error: {}", err_msg)).color(error_color)); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.token_creator_error_message = None; - } - }); - }); - ui.add_space(10.0); - } + // Elapsed display for token creation is handled by the global MessageBanner }); // Close the ScrollArea from line 40 @@ -1003,12 +969,20 @@ impl TokensScreen { fn update_selected_wallet(&mut self) { if let (Some(qid), Some(key)) = (&self.selected_identity, &self.selected_key) { - self.selected_wallet = crate::ui::identities::get_selected_wallet( - qid, - None, - Some(key), - &mut self.token_creator_error_message, - ); + let new_wallet = crate::ui::identities::get_selected_wallet(qid, None, Some(key)) + .unwrap_or_else(|e| { + MessageBanner::set_global(self.app_context.egui_ctx(), &e, MessageType::Error); + None + }); + let wallet_changed = match (&self.selected_wallet, &new_wallet) { + (Some(a), Some(b)) => !std::sync::Arc::ptr_eq(a, b), + (None, None) => false, + _ => true, + }; + if wallet_changed { + self.wallet_open_attempted = false; + } + self.selected_wallet = new_wallet; } } @@ -1018,8 +992,11 @@ impl TokensScreen { try_open_wallet_no_password, wallet_needs_unlock, }; - if let Err(e) = try_open_wallet_no_password(wallet) { - self.token_creator_error_message = Some(e); + if !self.wallet_open_attempted { + if let Err(e) = try_open_wallet_no_password(wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + } + self.wallet_open_attempted = true; } if wallet_needs_unlock(wallet) { @@ -1115,6 +1092,13 @@ impl TokensScreen { let token_names = self.parse_token_names(&mut contract_keywords)?; let token_description = if !self.token_description_input.is_empty() { + let len = self.token_description_input.chars().count(); + if !(3..=100).contains(&len) { + return Err( + "Token description must be either empty or between 3 and 100 characters long" + .to_string(), + ); + } Some(self.token_description_input.clone()) } else { None @@ -1476,7 +1460,7 @@ impl TokensScreen { match self.parse_token_build_args() { Ok(a) => a, Err(err) => { - self.token_creator_error_message = Some(err); + MessageBanner::set_global(ui.ctx(), &err, MessageType::Error); self.close_token_creator_confirmation_popup(); return AppAction::None; } @@ -1489,8 +1473,11 @@ impl TokensScreen { match (&self.selected_identity, &self.selected_key) { (Some(id), Some(key)) => (id.clone(), key.clone()), _ => { - self.token_creator_error_message = - Some("Please select an identity and signing key.".to_string()); + MessageBanner::set_global( + ui.ctx(), + "Please select an identity and signing key.", + MessageType::Error, + ); self.close_token_creator_confirmation_popup(); return AppAction::None; } @@ -1535,8 +1522,14 @@ impl TokensScreen { ]; action = AppAction::BackendTasks(tasks, BackendTasksExecutionMode::Sequential); - let now = Utc::now().timestamp() as u64; - self.token_creator_status = TokenCreatorStatus::WaitingForResult(now); + self.token_creator_status = TokenCreatorStatus::WaitingForResult; + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Creating token...", + MessageType::Info, + ); + handle.with_elapsed(); + self.operation_banner = Some(handle); self.close_token_creator_confirmation_popup(); } ConfirmationStatus::Canceled => { diff --git a/src/ui/tokens/transfer_tokens_screen.rs b/src/ui/tokens/transfer_tokens_screen.rs index 338d2faac..0b67d08c1 100644 --- a/src/ui/tokens/transfer_tokens_screen.rs +++ b/src/ui/tokens/transfer_tokens_screen.rs @@ -17,6 +17,7 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt, ResultBannerExt}; use crate::ui::helpers::{TransactionType, add_key_chooser}; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; @@ -24,45 +25,48 @@ use crate::ui::theme::DashColors; use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; -use dash_sdk::dpp::prelude::TimestampMillis; + use dash_sdk::platform::{Identifier, IdentityPublicKey}; use eframe::egui::{self, Context, Ui}; use eframe::egui::{Frame, Margin}; use egui::{Color32, RichText}; use std::collections::HashSet; use std::sync::{Arc, RwLock}; -use std::time::{SystemTime, UNIX_EPOCH}; use crate::ui::identities::get_selected_wallet; +use crate::ui::tokens::validate_signing_key; use super::tokens_screen::IdentityTokenBalance; #[derive(PartialEq)] pub enum TransferTokensStatus { NotStarted, - WaitingForResult(TimestampMillis), - ErrorMessage(String), + WaitingForResult, + Error, Complete, } pub struct TransferTokensScreen { - pub identity: QualifiedIdentity, + identity: Option, pub identity_token_balance: IdentityTokenBalance, known_identities: Vec, selected_key: Option, show_advanced_options: bool, - pub public_note: Option, - pub receiver_identity_id: String, - pub amount: Option, - pub amount_input: Option, + public_note: Option, + receiver_identity_id: String, + amount: Option, + amount_input: Option, transfer_tokens_status: TransferTokensStatus, max_amount: Amount, pub app_context: Arc, confirmation_dialog: Option, selected_wallet: Option>>, wallet_unlock_popup: WalletUnlockPopup, + wallet_open_attempted: bool, // Fee result from completed operation completed_fee_result: Option, + // Banner handle for elapsed time display + refresh_banner: Option, } impl TransferTokensScreen { @@ -72,24 +76,38 @@ impl TransferTokensScreen { ) -> Self { let known_identities = app_context .load_local_qualified_identities() - .expect("Identities not loaded"); + .or_show_error(app_context.egui_ctx()) + .unwrap_or_default(); let identity = known_identities .iter() .find(|identity| identity.identity.id() == identity_token_balance.identity_id) - .expect("Identity not found") - .clone(); + .cloned() + .or_else(|| { + MessageBanner::set_global( + app_context.egui_ctx(), + "Identity not found in local store — cannot open Transfer screen", + MessageType::Error, + ); + None + }); + let max_amount = Amount::from(&identity_token_balance); - let identity_clone = identity.identity.clone(); - let selected_key = identity_clone.get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ); - let mut error_message = None; - let selected_wallet = - get_selected_wallet(&identity, None, selected_key, &mut error_message); + let selected_key: Option = identity.as_ref().and_then(|id| { + id.identity + .get_first_public_key_matching( + Purpose::AUTHENTICATION, + HashSet::from([SecurityLevel::CRITICAL]), + KeyType::all_key_types().into(), + false, + ) + .cloned() + }); + let selected_wallet = identity.as_ref().and_then(|id| { + get_selected_wallet(id, None, selected_key.as_ref()) + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None) + }); let amount = Some(Amount::from(&identity_token_balance).with_value(0)); @@ -97,7 +115,7 @@ impl TransferTokensScreen { identity, identity_token_balance, known_identities, - selected_key: selected_key.cloned(), + selected_key, show_advanced_options: false, public_note: None, receiver_identity_id: String::new(), @@ -109,7 +127,9 @@ impl TransferTokensScreen { confirmation_dialog: None, selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), + wallet_open_attempted: false, completed_fee_result: None, + refresh_banner: None, } } @@ -139,8 +159,8 @@ impl TransferTokensScreen { // Check if input should be disabled when operation is in progress let enabled = match self.transfer_tokens_status { - TransferTokensStatus::WaitingForResult(_) | TransferTokensStatus::Complete => false, - TransferTokensStatus::NotStarted | TransferTokensStatus::ErrorMessage(_) => { + TransferTokensStatus::WaitingForResult | TransferTokensStatus::Complete => false, + TransferTokensStatus::NotStarted | TransferTokensStatus::Error => { amount_input.set_max_amount(Some(self.max_amount.value())); true } @@ -153,6 +173,12 @@ impl TransferTokensScreen { } fn render_to_identity_input(&mut self, ui: &mut Ui) { + let exclude: Vec<_> = self + .identity + .as_ref() + .map(|id| id.identity.id()) + .into_iter() + .collect(); let _response = ui.add( IdentitySelector::new( "transfer_recipient_selector", @@ -161,7 +187,7 @@ impl TransferTokensScreen { ) .width(300.0) .label("Recipient:") - .exclude(&[self.identity.identity.id()]), + .exclude(&exclude), ); } @@ -193,52 +219,74 @@ impl TransferTokensScreen { } fn confirmation_ok(&mut self) -> AppAction { - let signing_key = match self.selected_key.clone() { - Some(key) => key, - None => { - self.transfer_tokens_status = - TransferTokensStatus::ErrorMessage("No signing key selected".into()); - return AppAction::None; - } - }; - if self.amount.is_none() || self.amount == Some(Amount::new(0, 0)) { - self.transfer_tokens_status = - TransferTokensStatus::ErrorMessage("Invalid amount".into()); + self.transfer_tokens_status = TransferTokensStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Invalid amount", + MessageType::Error, + ); return AppAction::None; } - let receiver_id = match Identifier::from_string_try_encodings( + let Ok(receiver_id) = Identifier::from_string_try_encodings( &self.receiver_identity_id, &[ dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58, dash_sdk::dpp::platform_value::string_encoding::Encoding::Hex, ], - ) { - Ok(id) => id, - Err(_) => { - self.transfer_tokens_status = - TransferTokensStatus::ErrorMessage("Invalid receiver".into()); + ) else { + self.transfer_tokens_status = TransferTokensStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Invalid receiver", + MessageType::Error, + ); + return AppAction::None; + }; + + // Validate signing key before transitioning to waiting state + let Some(signing_key) = validate_signing_key(&self.app_context, self.selected_key.as_ref()) + else { + return AppAction::None; + }; + + let data_contract = match self + .app_context + .get_unqualified_contract_by_id(&self.identity_token_balance.data_contract_id) + { + Ok(Some(contract)) => Arc::new(contract), + _ => { + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Data contract not found", + MessageType::Error, + ); return AppAction::None; } }; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.transfer_tokens_status = TransferTokensStatus::WaitingForResult(now); - - let data_contract = Arc::new( - self.app_context - .get_unqualified_contract_by_id(&self.identity_token_balance.data_contract_id) - .expect("Failed to get data contract") - .expect("Data contract not found"), + let Some(identity) = self.identity.clone() else { + MessageBanner::set_global( + self.app_context.egui_ctx(), + "No identity loaded — cannot transfer tokens", + MessageType::Error, + ); + return AppAction::None; + }; + + self.transfer_tokens_status = TransferTokensStatus::WaitingForResult; + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Transferring tokens...", + MessageType::Info, ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); AppAction::BackendTask(BackendTask::TokenTask(Box::new( TokenTask::TransferTokens { - sending_identity: self.identity.clone(), + sending_identity: identity, recipient_id: receiver_id, amount: self.amount.clone().unwrap_or(Amount::new(0, 0)).value(), data_contract, @@ -259,15 +307,18 @@ impl TransferTokensScreen { } impl ScreenLike for TransferTokensScreen { - fn display_message(&mut self, message: &str, message_type: MessageType) { - if let MessageType::Error = message_type { - self.transfer_tokens_status = TransferTokensStatus::ErrorMessage(message.to_string()); + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.refresh_banner.take_and_clear(); + self.transfer_tokens_status = TransferTokensStatus::Error; } } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { if let BackendTaskSuccessResult::TransferredTokens(fee_result) = backend_task_success_result { + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.transfer_tokens_status = TransferTokensStatus::Complete; } @@ -275,23 +326,55 @@ impl ScreenLike for TransferTokensScreen { fn refresh(&mut self) { // Refresh the identity because there might be new keys - self.identity = self - .app_context - .load_local_qualified_identities() - .unwrap() - .into_iter() - .find(|identity| identity.identity.id() == self.identity.identity.id()) - .unwrap(); - let token_balances = self - .app_context - .db - .get_identity_token_balances(&self.app_context) - .expect("Token balances not loaded"); - self.max_amount = token_balances - .values() - .find(|balance| balance.identity_id == self.identity.identity.id()) - .map(Amount::from) - .unwrap_or_default(); + if let Some(current_id) = self.identity.as_ref().map(|id| id.identity.id()) { + let all_ids = self + .app_context + .load_local_qualified_identities() + .unwrap_or_else(|e| { + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Failed to load local identities", + MessageType::Error, + ) + .with_details(e); + vec![] + }); + if let Some(refreshed) = all_ids + .into_iter() + .find(|identity| identity.identity.id() == current_id) + { + self.identity = Some(refreshed); + } + } + if let Some(current_id) = self.identity.as_ref().map(|id| id.identity.id()) { + match self + .app_context + .db + .get_identity_token_balances(&self.app_context) + { + Ok(token_balances) => { + self.max_amount = token_balances + .values() + .find(|balance| { + balance.identity_id == current_id + && balance.data_contract_id + == self.identity_token_balance.data_contract_id + && balance.token_position + == self.identity_token_balance.token_position + }) + .map(Amount::from) + .unwrap_or_default(); + } + Err(e) => { + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Failed to load token balances", + MessageType::Error, + ) + .with_details(e); + } + } + } } /// Renders the UI components for the withdrawal screen @@ -328,6 +411,16 @@ impl ScreenLike for TransferTokensScreen { return self.show_success(ui); } + // Guard: require a loaded identity before rendering interactive content. + // Use as_ref() to avoid a per-frame clone of the full QualifiedIdentity. + let Some(identity) = self.identity.as_ref() else { + ui.colored_label( + DashColors::error_color(dark_mode), + "No identity loaded. Please load an identity and reopen this screen.", + ); + return AppAction::None; + }; + ui.heading(format!( "Transfer {}", self.identity_token_balance.token_alias @@ -335,36 +428,41 @@ impl ScreenLike for TransferTokensScreen { ui.add_space(10.0); let has_keys = if self.app_context.is_developer_mode() { - !self.identity.identity.public_keys().is_empty() + !identity.identity.public_keys().is_empty() } else { - !self - .identity + !identity .available_authentication_keys_with_critical_security_level() .is_empty() }; if !has_keys { + let identity_type = identity.identity_type; + // Extract key data before releasing the borrow (needed for click handlers). + let key = identity + .identity + .get_first_public_key_matching( + Purpose::AUTHENTICATION, + HashSet::from([SecurityLevel::CRITICAL]), + KeyType::all_key_types().into(), + false, + ) + .cloned(); ui.colored_label( DashColors::error_color(dark_mode), format!( "You do not have any authentication keys with CRITICAL security level loaded for this {} identity.", - self.identity.identity_type + identity_type ), ); ui.add_space(10.0); - let key = self.identity.identity.get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ); - if let Some(key) = key { if ui.button("Check Keys").clicked() { + // Clone only on button click, not every frame. + let identity = self.identity.clone().expect("checked above"); return AppAction::AddScreen(Screen::KeyInfoScreen(KeyInfoScreen::new( - self.identity.clone(), - key.clone(), + identity, + key, None, &self.app_context, ))); @@ -373,15 +471,20 @@ impl ScreenLike for TransferTokensScreen { } if ui.button("Add key").clicked() { + // Clone only on button click, not every frame. + let identity = self.identity.clone().expect("checked above"); return AppAction::AddScreen(Screen::AddKeyScreen(AddKeyScreen::new( - self.identity.clone(), + identity, &self.app_context, ))); } } else { if let Some(wallet) = &self.selected_wallet { - if let Err(e) = try_open_wallet_no_password(wallet) { - self.transfer_tokens_status = TransferTokensStatus::ErrorMessage(e); + if !self.wallet_open_attempted { + if let Err(e) = try_open_wallet_no_password(wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + } + self.wallet_open_attempted = true; } if wallet_needs_unlock(wallet) { ui.add_space(10.0); @@ -410,10 +513,12 @@ impl ScreenLike for TransferTokensScreen { if self.show_advanced_options { ui.heading("1. Select the key to sign the transaction with"); ui.add_space(10.0); + // Reborrow identity as ref for add_key_chooser; selected_key is &mut self. + let identity = self.identity.as_ref().expect("checked above"); add_key_chooser( ui, &self.app_context, - &self.identity, + identity, &mut self.selected_key, TransactionType::TokenTransfer, ); @@ -490,7 +595,8 @@ impl ScreenLike for TransferTokensScreen { // Transfer button - let has_enough_balance = self.identity.identity.balance() > estimated_fee; + let identity = self.identity.as_ref().expect("checked above"); + let has_enough_balance = identity.identity.balance() > estimated_fee; let ready = self.amount.is_some() && !self.receiver_identity_id.is_empty() && self.selected_key.is_some() @@ -518,12 +624,18 @@ impl ScreenLike for TransferTokensScreen { { // Use the amount value directly since it's already parsed if self.amount.as_ref().is_some_and(|v| v > &self.max_amount) { - self.transfer_tokens_status = TransferTokensStatus::ErrorMessage( - "Amount exceeds available balance".to_string(), + self.transfer_tokens_status = TransferTokensStatus::Error; + MessageBanner::set_global( + ui.ctx(), + "Amount exceeds available balance", + MessageType::Error, ); } else if self.amount.as_ref().is_none_or(|a| a.value() == 0) { - self.transfer_tokens_status = TransferTokensStatus::ErrorMessage( - "Amount must be greater than zero".to_string(), + self.transfer_tokens_status = TransferTokensStatus::Error; + MessageBanner::set_global( + ui.ctx(), + "Amount must be greater than zero", + MessageType::Error, ); } else { let msg = format!( @@ -549,41 +661,11 @@ impl ScreenLike for TransferTokensScreen { TransferTokensStatus::NotStarted => { // Do nothing } - TransferTokensStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed_seconds = now - start_time; - - let display_time = if elapsed_seconds < 60 { - format!( - "{} second{}", - elapsed_seconds, - if elapsed_seconds == 1 { "" } else { "s" } - ) - } else { - let minutes = elapsed_seconds / 60; - let seconds = elapsed_seconds % 60; - format!( - "{} minute{} and {} second{}", - minutes, - if minutes == 1 { "" } else { "s" }, - seconds, - if seconds == 1 { "" } else { "s" } - ) - }; - - ui.label(format!( - "Transferring... Time taken so far: {}", - display_time - )); + TransferTokensStatus::WaitingForResult => { + // Elapsed display is handled by the global MessageBanner } - TransferTokensStatus::ErrorMessage(msg) => { - ui.colored_label( - DashColors::error_color(dark_mode), - format!("Error: {}", msg), - ); + TransferTokensStatus::Error => { + // Error display is handled by the global MessageBanner } TransferTokensStatus::Complete => { // Handled above diff --git a/src/ui/tokens/unfreeze_tokens_screen.rs b/src/ui/tokens/unfreeze_tokens_screen.rs index 90aab2d03..29862668f 100644 --- a/src/ui/tokens/unfreeze_tokens_screen.rs +++ b/src/ui/tokens/unfreeze_tokens_screen.rs @@ -16,11 +16,13 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::helpers::{TransactionType, add_key_chooser, render_group_action_text}; use crate::ui::identities::get_selected_wallet; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; use crate::ui::theme::DashColors; +use crate::ui::tokens::validate_signing_key; use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dpp::data_contract::GroupContractPosition; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -38,20 +40,19 @@ use eframe::egui::{self, Color32, Context, Frame, Margin, Ui}; use egui::RichText; use std::collections::HashSet; use std::sync::{Arc, RwLock}; -use std::time::{SystemTime, UNIX_EPOCH}; /// The states for the unfreeze flow #[derive(PartialEq)] pub enum UnfreezeTokensStatus { NotStarted, - WaitingForResult(u64), - ErrorMessage(String), + WaitingForResult, + Error, Complete, } /// A screen that allows unfreezing a previously frozen identity's tokens for a specific contract pub struct UnfreezeTokensScreen { - pub identity: QualifiedIdentity, + identity: QualifiedIdentity, pub identity_token_info: IdentityTokenInfo, selected_key: Option, show_advanced_options: bool, @@ -69,7 +70,6 @@ pub struct UnfreezeTokensScreen { pub unfreeze_identity_id: String, status: UnfreezeTokensStatus, - error_message: Option, // Basic references pub app_context: Arc, @@ -80,16 +80,17 @@ pub struct UnfreezeTokensScreen { // If password-based wallet unlocking is needed selected_wallet: Option>>, wallet_unlock_popup: WalletUnlockPopup, + wallet_open_attempted: bool, // Fee result from completed operation completed_fee_result: Option, + // Banner handle for elapsed time display + refresh_banner: Option, } impl UnfreezeTokensScreen { pub fn new(identity_token_info: IdentityTokenInfo, app_context: &Arc) -> Self { // TODO: filter to include only frozen identities - let frozen_identities = app_context - .load_local_qualified_identities() - .expect("Identities not loaded"); + let frozen_identities = super::load_identities_with_banner(app_context); let possible_key = identity_token_info .identity @@ -102,7 +103,7 @@ impl UnfreezeTokensScreen { ) .cloned(); - let mut error_message = None; + let set_error_banner = |msg: &str| super::set_error_banner(app_context, msg); let group = match identity_token_info .token_config @@ -110,32 +111,30 @@ impl UnfreezeTokensScreen { .authorized_to_make_change_action_takers() { AuthorizedActionTakers::NoOne => { - error_message = Some("Burning is not allowed on this token".to_string()); + set_error_banner("Unfreezing is not allowed on this token"); None } AuthorizedActionTakers::ContractOwner => { if identity_token_info.data_contract.contract.owner_id() != identity_token_info.identity.identity.id() { - error_message = Some( - "You are not allowed to burn this token. Only the contract owner is." - .to_string(), + set_error_banner( + "You are not allowed to unfreeze this token. Only the contract owner is.", ); } None } AuthorizedActionTakers::Identity(identifier) => { if identifier != &identity_token_info.identity.identity.id() { - error_message = Some("You are not allowed to burn this token".to_string()); + set_error_banner("You are not allowed to unfreeze this token"); } None } AuthorizedActionTakers::MainGroup => { match identity_token_info.token_config.main_control_group() { None => { - error_message = Some( - "Invalid contract: No main control group, though one should exist" - .to_string(), + set_error_banner( + "Invalid contract: No main control group, though one should exist", ); None } @@ -147,7 +146,7 @@ impl UnfreezeTokensScreen { { Ok(group) => Some((group_pos, group.clone())), Err(e) => { - error_message = Some(format!("Invalid contract: {}", e)); + set_error_banner(&format!("Invalid contract: {}", e)); None } } @@ -162,7 +161,7 @@ impl UnfreezeTokensScreen { { Ok(group) => Some((*group_pos, group.clone())), Err(e) => { - error_message = Some(format!("Invalid contract: {}", e)); + set_error_banner(&format!("Invalid contract: {}", e)); None } } @@ -185,12 +184,12 @@ impl UnfreezeTokensScreen { }; // Attempt to get an unlocked wallet reference - let selected_wallet = get_selected_wallet( - &identity_token_info.identity, - None, - possible_key.as_ref(), - &mut error_message, - ); + let selected_wallet = + get_selected_wallet(&identity_token_info.identity, None, possible_key.as_ref()) + .unwrap_or_else(|e| { + set_error_banner(&e); + None + }); Self { identity: identity_token_info.identity.clone(), @@ -203,13 +202,14 @@ impl UnfreezeTokensScreen { public_note: None, unfreeze_identity_id: String::new(), status: UnfreezeTokensStatus::NotStarted, - error_message, app_context: app_context.clone(), confirmation_dialog: None, selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), + wallet_open_attempted: false, frozen_identities, completed_fee_result: None, + refresh_banner: None, } } @@ -252,36 +252,37 @@ impl UnfreezeTokensScreen { } fn confirmation_ok(&mut self) -> AppAction { - let signing_key = match self.selected_key.clone() { - Some(key) => key, - None => { - self.error_message = Some("No signing key selected".into()); - self.status = UnfreezeTokensStatus::ErrorMessage("No key selected".into()); - return AppAction::None; - } - }; - // Validate user input - let unfreeze_id = match Identifier::from_string_try_encodings( + let Ok(unfreeze_id) = Identifier::from_string_try_encodings( &self.unfreeze_identity_id, &[ dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58, dash_sdk::dpp::platform_value::string_encoding::Encoding::Hex, ], - ) { - Ok(id) => id, - Err(_) => { - self.error_message = Some("Please enter a valid identity ID.".into()); - self.status = UnfreezeTokensStatus::ErrorMessage("Invalid identity ID".into()); - return AppAction::None; - } + ) else { + self.status = UnfreezeTokensStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Please enter a valid identity ID.", + MessageType::Error, + ); + return AppAction::None; + }; + + // Validate signing key before transitioning to waiting state + let Some(signing_key) = validate_signing_key(&self.app_context, self.selected_key.as_ref()) + else { + return AppAction::None; }; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.status = UnfreezeTokensStatus::WaitingForResult(now); + self.status = UnfreezeTokensStatus::WaitingForResult; + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Unfreezing tokens...", + MessageType::Info, + ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); // Grab the data contract for this token from the app context let data_contract = Arc::new(self.identity_token_info.data_contract.contract.clone()); @@ -334,15 +335,17 @@ impl UnfreezeTokensScreen { } impl ScreenLike for UnfreezeTokensScreen { - fn display_message(&mut self, message: &str, message_type: MessageType) { - if let MessageType::Error = message_type { - self.status = UnfreezeTokensStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.refresh_banner.take_and_clear(); + self.status = UnfreezeTokensStatus::Error; } } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { if let BackendTaskSuccessResult::UnfrozeTokens(fee_result) = backend_task_success_result { + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.status = UnfreezeTokensStatus::Complete; } @@ -454,8 +457,11 @@ impl ScreenLike for UnfreezeTokensScreen { } else { // Possibly handle locked wallet scenario if let Some(wallet) = &self.selected_wallet { - if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + if !self.wallet_open_attempted { + if let Err(e) = try_open_wallet_no_password(wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + } + self.wallet_open_attempted = true; } if wallet_needs_unlock(wallet) { ui.add_space(10.0); @@ -609,33 +615,11 @@ impl ScreenLike for UnfreezeTokensScreen { UnfreezeTokensStatus::NotStarted => { // no-op } - UnfreezeTokensStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed = now - start_time; - ui.label(format!("Unfreezing... elapsed: {}s", elapsed)); + UnfreezeTokensStatus::WaitingForResult => { + // Elapsed display is handled by the global MessageBanner } - UnfreezeTokensStatus::ErrorMessage(msg) => { - let error_color = DashColors::ERROR; - let msg = msg.clone(); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new(format!("Error: {}", msg)).color(error_color), - ); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.status = UnfreezeTokensStatus::NotStarted; - } - }); - }); + UnfreezeTokensStatus::Error => { + // Error display is handled by the global MessageBanner } UnfreezeTokensStatus::Complete => { // handled above diff --git a/src/ui/tokens/update_token_config.rs b/src/ui/tokens/update_token_config.rs index 1de17195f..09631609f 100644 --- a/src/ui/tokens/update_token_config.rs +++ b/src/ui/tokens/update_token_config.rs @@ -13,13 +13,14 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{MessageBanner, OptionBannerExt, ResultBannerExt}; use crate::ui::helpers::{TransactionType, add_key_chooser, render_group_action_text}; use crate::ui::identities::get_selected_wallet; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; use crate::ui::theme::DashColors; +use crate::ui::tokens::validate_signing_key; use crate::ui::{MessageType, Screen, ScreenLike}; -use chrono::{DateTime, Utc}; use dash_sdk::dpp::data_contract::GroupContractPosition; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; @@ -36,24 +37,24 @@ use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::{DataContract, Identifier, IdentityPublicKey}; use eframe::egui::{self, Color32, Context, Ui}; -use egui::{Frame, Margin, RichText}; +use egui::RichText; use std::collections::HashSet; use std::sync::{Arc, RwLock}; #[derive(Debug, Clone, PartialEq)] pub enum UpdateTokenConfigStatus { NotUpdating, - Updating(DateTime), + Updating, + Complete, } pub struct UpdateTokenConfigScreen { pub identity_token_info: IdentityTokenInfo, - backend_message: Option<(String, MessageType, DateTime)>, update_status: UpdateTokenConfigStatus, pub app_context: Arc, pub change_item: TokenConfigurationChangeItem, - pub update_text: String, - pub text_input_error: String, + update_text: String, + text_input_error: String, signing_key: Option, show_advanced_options: bool, identity: QualifiedIdentity, @@ -63,14 +64,16 @@ pub struct UpdateTokenConfigScreen { pub group_action_id: Option, // Input state fields - pub authorized_identity_input: Option, - pub authorized_group_input: Option, + authorized_identity_input: Option, + authorized_group_input: Option, selected_wallet: Option>>, wallet_unlock_popup: WalletUnlockPopup, - error_message: Option, // unused + wallet_open_attempted: bool, // Fee result from completed operation completed_fee_result: Option, + // Banner handle for elapsed time display + refresh_banner: Option, } impl UpdateTokenConfigScreen { @@ -86,8 +89,6 @@ impl UpdateTokenConfigScreen { ) .cloned(); - let mut error_message = None; - // Initialize with no group - will be set when user selects a change item let group = None; @@ -95,16 +96,13 @@ impl UpdateTokenConfigScreen { let is_unilateral_group_member = false; // Attempt to get an unlocked wallet reference - let selected_wallet = get_selected_wallet( - &identity_token_info.identity, - None, - possible_key.as_ref(), - &mut error_message, - ); + let selected_wallet = + get_selected_wallet(&identity_token_info.identity, None, possible_key.as_ref()) + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None); Self { identity_token_info: identity_token_info.clone(), - backend_message: None, update_status: UpdateTokenConfigStatus::NotUpdating, app_context: app_context.clone(), change_item: TokenConfigurationChangeItem::TokenConfigurationNoChange, @@ -119,13 +117,14 @@ impl UpdateTokenConfigScreen { selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), - error_message, + wallet_open_attempted: false, identity: identity_token_info.identity, group, is_unilateral_group_member, group_action_id: None, completed_fee_result: None, + refresh_banner: None, } } @@ -135,35 +134,40 @@ impl UpdateTokenConfigScreen { .token_config .authorized_action_takers_for_configuration_item(&self.change_item); - let mut error_message = None; let group = match authorized_action_takers { AuthorizedActionTakers::NoOne => { - error_message = Some("This action is not allowed on this token".to_string()); + super::set_error_banner( + &self.app_context, + "This action is not allowed on this token", + ); None } AuthorizedActionTakers::ContractOwner => { if self.identity_token_info.data_contract.contract.owner_id() != self.identity_token_info.identity.identity.id() { - error_message = Some( - "You are not allowed to perform this action. Only the contract owner is." - .to_string(), + super::set_error_banner( + &self.app_context, + "You are not allowed to perform this action. Only the contract owner is.", ); } None } AuthorizedActionTakers::Identity(identifier) => { if identifier != self.identity_token_info.identity.identity.id() { - error_message = Some("You are not allowed to perform this action".to_string()); + super::set_error_banner( + &self.app_context, + "You are not allowed to perform this action", + ); } None } AuthorizedActionTakers::MainGroup => { match self.identity_token_info.token_config.main_control_group() { None => { - error_message = Some( - "Invalid contract: No main control group, though one should exist" - .to_string(), + super::set_error_banner( + &self.app_context, + "Invalid contract: No main control group, though one should exist", ); None } @@ -176,7 +180,10 @@ impl UpdateTokenConfigScreen { { Ok(group) => Some((group_pos, group.clone())), Err(e) => { - error_message = Some(format!("Invalid contract: {}", e)); + super::set_error_banner( + &self.app_context, + &format!("Invalid contract: {}", e), + ); None } } @@ -192,7 +199,10 @@ impl UpdateTokenConfigScreen { { Ok(group) => Some((group_pos, group.clone())), Err(e) => { - error_message = Some(format!("Invalid contract: {}", e)); + super::set_error_banner( + &self.app_context, + &format!("Invalid contract: {}", e), + ); None } } @@ -200,11 +210,6 @@ impl UpdateTokenConfigScreen { }; self.group = group; - if let Some(error) = error_message { - self.error_message = Some(error); - } else { - self.error_message = None; - } // Update is_unilateral_group_member based on new group self.is_unilateral_group_member = false; @@ -751,12 +756,12 @@ impl UpdateTokenConfigScreen { { ui.add_space(20.0); if ui.add(button).clicked() { - let group_info = if self.group_action_id.is_some() { + let group_info = if let Some(action_id) = self.group_action_id { self.group.as_ref().map(|(pos, _)| { GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( GroupStateTransitionInfo { group_contract_position: *pos, - action_id: self.group_action_id.unwrap(), + action_id, action_is_proposer: false, }, ) @@ -767,20 +772,31 @@ impl UpdateTokenConfigScreen { }) }; - self.update_status = UpdateTokenConfigStatus::Updating(Utc::now()); - action |= AppAction::BackendTask(BackendTask::TokenTask(Box::new( - TokenTask::UpdateTokenConfig { - identity_token_info: Box::new(self.identity_token_info.clone()), - change_item: self.change_item.clone(), - signing_key: self.signing_key.clone().expect("Signing key must be set"), - public_note: if self.group_action_id.is_some() { - None - } else { - self.public_note.clone() + if let Some(signing_key) = + validate_signing_key(&self.app_context, self.signing_key.as_ref()) + { + self.update_status = UpdateTokenConfigStatus::Updating; + let handle = MessageBanner::set_global( + ui.ctx(), + "Updating token configuration...", + MessageType::Info, + ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); + action |= AppAction::BackendTask(BackendTask::TokenTask(Box::new( + TokenTask::UpdateTokenConfig { + identity_token_info: Box::new(self.identity_token_info.clone()), + change_item: self.change_item.clone(), + signing_key, + public_note: if self.group_action_id.is_some() { + None + } else { + self.public_note.clone() + }, + group_info, }, - group_info, - }, - ))); + ))); + } } } @@ -917,38 +933,21 @@ impl UpdateTokenConfigScreen { } impl ScreenLike for UpdateTokenConfigScreen { - fn display_message(&mut self, message: &str, message_type: MessageType) { - match message_type { - MessageType::Error => { - self.backend_message = Some((message.to_string(), MessageType::Error, Utc::now())); - self.update_status = UpdateTokenConfigStatus::NotUpdating; - } - MessageType::Info => { - self.backend_message = Some((message.to_string(), MessageType::Info, Utc::now())); - } - _ => {} + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.refresh_banner.take_and_clear(); + self.update_status = UpdateTokenConfigStatus::NotUpdating; } } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { - if let BackendTaskSuccessResult::UpdatedTokenConfig(change_item, fee_result) = + if let BackendTaskSuccessResult::UpdatedTokenConfig(_change_item, fee_result) = backend_task_success_result { - self.completed_fee_result = Some(fee_result.clone()); - let fee_info = format!( - " (Fee: Estimated {} • Actual {})", - format_credits_as_dash(fee_result.estimated_fee), - format_credits_as_dash(fee_result.actual_fee) - ); - self.backend_message = Some(( - format!( - "Successfully updated token config item: {}{}", - change_item, fee_info - ), - MessageType::Success, - Utc::now(), - )); - self.update_status = UpdateTokenConfigStatus::NotUpdating; + self.refresh_banner.take_and_clear(); + self.completed_fee_result = Some(fee_result); + self.update_status = UpdateTokenConfigStatus::Complete; } } @@ -993,11 +992,10 @@ impl ScreenLike for UpdateTokenConfigScreen { // Central panel island_central_panel(ctx, |ui| { egui::ScrollArea::vertical().show(ui, |ui| { - if let Some(msg) = &self.backend_message - && msg.1 == MessageType::Success { - action |= self.show_success_screen(ui); - return; - } + if self.update_status == UpdateTokenConfigStatus::Complete { + action |= self.show_success_screen(ui); + return; + } ui.heading("Update Token Configuration"); ui.add_space(10.0); @@ -1051,8 +1049,11 @@ impl ScreenLike for UpdateTokenConfigScreen { } else { // Possibly handle locked wallet scenario (similar to TransferTokens) if let Some(wallet) = &self.selected_wallet { - if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + if !self.wallet_open_attempted { + if let Err(e) = try_open_wallet_no_password(wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + } + self.wallet_open_attempted = true; } if wallet_needs_unlock(wallet) { ui.add_space(10.0); @@ -1095,43 +1096,6 @@ impl ScreenLike for UpdateTokenConfigScreen { action |= self.render_token_config_updater(ui); - if let Some((msg, msg_type, _)) = self.backend_message.clone() { - ui.add_space(10.0); - match msg_type { - MessageType::Success => { - ui.colored_label(Color32::DARK_GREEN, &msg); - } - MessageType::Error | MessageType::Warning => { - let dark_mode = ui.ctx().style().visuals.dark_mode; - let error_color = DashColors::error_color(dark_mode); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new(format!("Error: {}", msg)).color(error_color)); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.backend_message = None; - } - }); - }); - } - MessageType::Info => { - ui.label(&msg); - } - }; - } - - if self.update_status != UpdateTokenConfigStatus::NotUpdating { - ui.add_space(10.0); - if let UpdateTokenConfigStatus::Updating(start_time) = &self.update_status { - let elapsed = Utc::now().signed_duration_since(*start_time); - ui.label(format!("Updating... ({} seconds)", elapsed.num_seconds())); - } - } } }); // end of ScrollArea }); diff --git a/src/ui/tokens/view_token_claims_screen.rs b/src/ui/tokens/view_token_claims_screen.rs index adc713c16..a72489ec1 100644 --- a/src/ui/tokens/view_token_claims_screen.rs +++ b/src/ui/tokens/view_token_claims_screen.rs @@ -2,6 +2,7 @@ use crate::app::{AppAction, DesiredAppAction}; use crate::backend_task::document::DocumentTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; +use crate::ui::components::MessageBanner; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; @@ -28,7 +29,6 @@ pub enum FetchStatus { pub struct ViewTokenClaimsScreen { pub identity_token_basic_info: IdentityTokenBasicInfo, pub new_claims_query: DocumentQuery, - message: Option<(String, MessageType, DateTime)>, fetch_status: FetchStatus, pub app_context: Arc, claims: Vec, @@ -60,7 +60,6 @@ impl ViewTokenClaimsScreen { limit: 0, start: None, }, - message: None, fetch_status: FetchStatus::NotFetching, app_context: app_context.clone(), claims: vec![], @@ -70,19 +69,14 @@ impl ViewTokenClaimsScreen { impl ScreenLike for ViewTokenClaimsScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. match message_type { - MessageType::Success => { - self.message = Some((message.to_string(), MessageType::Success, Utc::now())); - } MessageType::Error | MessageType::Warning => { - self.message = Some((message.to_string(), message_type, Utc::now())); if message.contains("Error fetching documents") { self.fetch_status = FetchStatus::NotFetching; } } - MessageType::Info => { - self.message = Some((message.to_string(), MessageType::Info, Utc::now())); - } + _ => {} } } @@ -92,7 +86,11 @@ impl ScreenLike for ViewTokenClaimsScreen { self.claims = documents.into_iter().filter_map(|(_, doc)| doc).collect(); if self.claims.is_empty() { - self.display_message("No claims found", MessageType::Info); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "No claims found", + MessageType::Info, + ); } } } @@ -130,7 +128,6 @@ impl ScreenLike for ViewTokenClaimsScreen { // Central panel island_central_panel(ctx, |ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; ui.heading("View Token Claims"); ui.add_space(10.0); @@ -147,20 +144,7 @@ impl ScreenLike for ViewTokenClaimsScreen { self.fetch_status = FetchStatus::Fetching(Utc::now()) } - if let Some((msg, msg_type, _)) = &self.message { - ui.add_space(10.0); - match msg_type { - MessageType::Success => { - ui.colored_label(DashColors::success_color(dark_mode), msg); - } - MessageType::Error | MessageType::Warning => { - ui.colored_label(DashColors::error_color(dark_mode), msg); - } - MessageType::Info => { - ui.label(msg); - } - }; - } + // Message display is handled by the global MessageBanner if self.fetch_status != FetchStatus::NotFetching { ui.add_space(10.0); diff --git a/src/ui/tools/address_balance_screen.rs b/src/ui/tools/address_balance_screen.rs index e34a04907..165aeccae 100644 --- a/src/ui/tools/address_balance_screen.rs +++ b/src/ui/tools/address_balance_screen.rs @@ -2,13 +2,13 @@ use crate::app::AppAction; use crate::backend_task::platform_info::{PlatformInfoTaskRequestType, PlatformInfoTaskResult}; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; +use crate::ui::components::MessageBanner; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tools_subscreen_chooser_panel::add_tools_subscreen_chooser_panel; use crate::ui::components::top_panel::add_top_panel; -use crate::ui::theme::DashColors; use crate::ui::{MessageType, ScreenLike}; -use eframe::egui::{self, Context, Frame, Margin, RichText, ScrollArea, TextEdit, Ui}; +use eframe::egui::{self, Context, ScrollArea, TextEdit, Ui}; use std::sync::Arc; pub struct AddressBalanceScreen { @@ -16,7 +16,6 @@ pub struct AddressBalanceScreen { address_input: String, is_loading: bool, result: Option, - error_message: Option, } #[derive(Clone, Debug)] @@ -33,19 +32,21 @@ impl AddressBalanceScreen { address_input: String::new(), is_loading: false, result: None, - error_message: None, } } fn trigger_fetch(&mut self) -> AppAction { let address = self.address_input.trim().to_string(); if address.is_empty() { - self.error_message = Some("Please enter an address".to_string()); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Please enter an address", + MessageType::Error, + ); return AppAction::None; } self.is_loading = true; - self.error_message = None; self.result = None; let task = @@ -92,26 +93,6 @@ impl AddressBalanceScreen { } fn render_result(&mut self, ui: &mut Ui) { - if let Some(ref error) = self.error_message { - ui.add_space(20.0); - let error_color = DashColors::ERROR; - let error = error.clone(); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new(format!("Error: {}", error)).color(error_color)); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.error_message = None; - } - }); - }); - } - if let Some(ref result) = self.result { ui.add_space(20.0); ui.separator(); @@ -143,9 +124,10 @@ impl AddressBalanceScreen { } impl ScreenLike for AddressBalanceScreen { - fn display_message(&mut self, message: &str, message_type: MessageType) { - if message_type == MessageType::Error { - self.error_message = Some(message.to_string()); + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.is_loading = false; } } diff --git a/src/ui/tools/contract_visualizer_screen.rs b/src/ui/tools/contract_visualizer_screen.rs index 816b5242c..ecc7a6915 100644 --- a/src/ui/tools/contract_visualizer_screen.rs +++ b/src/ui/tools/contract_visualizer_screen.rs @@ -172,10 +172,10 @@ impl ContractVisualizerScreen { // ======================= 2. ScreenLike impl ======================= impl crate::ui::ScreenLike for ContractVisualizerScreen { - fn display_message(&mut self, msg: &str, t: crate::ui::MessageType) { - if matches!(t, crate::ui::MessageType::Error) { - self.parse_status = ContractParseStatus::Error(msg.to_owned()); - } + fn display_message(&mut self, _msg: &str, _t: crate::ui::MessageType) { + // INTENTIONAL: These screens perform only local synchronous parsing. + // No backend tasks are dispatched, so no error/success messages arrive here. + // Local parse errors are set directly via self.parse_status. } fn display_task_result(&mut self, _r: BackendTaskSuccessResult) {} fn ui(&mut self, ctx: &Context) -> AppAction { diff --git a/src/ui/tools/document_visualizer_screen.rs b/src/ui/tools/document_visualizer_screen.rs index 0997f74ae..bd2bc5633 100644 --- a/src/ui/tools/document_visualizer_screen.rs +++ b/src/ui/tools/document_visualizer_screen.rs @@ -194,10 +194,10 @@ impl DocumentVisualizerScreen { // ======================= 2. ScreenLike impl ======================= impl crate::ui::ScreenLike for DocumentVisualizerScreen { - fn display_message(&mut self, msg: &str, t: crate::ui::MessageType) { - if matches!(t, crate::ui::MessageType::Error) { - self.parse_status = DocumentParseStatus::Error(msg.to_owned()); - } + fn display_message(&mut self, _msg: &str, _t: crate::ui::MessageType) { + // INTENTIONAL: These screens perform only local synchronous parsing. + // No backend tasks are dispatched, so no error/success messages arrive here. + // Local parse errors are set directly via self.parse_status. } fn display_task_result(&mut self, _r: BackendTaskSuccessResult) {} fn ui(&mut self, ctx: &Context) -> AppAction { diff --git a/src/ui/tools/grovestark_screen.rs b/src/ui/tools/grovestark_screen.rs index e835a9db7..a1e6944a9 100644 --- a/src/ui/tools/grovestark_screen.rs +++ b/src/ui/tools/grovestark_screen.rs @@ -3,13 +3,14 @@ use crate::backend_task::BackendTask; use crate::backend_task::grovestark::GroveSTARKTask; use crate::context::AppContext; use crate::model::qualified_identity::{PrivateKeyTarget, QualifiedIdentity}; -use crate::ui::RootScreenType; use crate::ui::ScreenLike; +use crate::ui::components::MessageBanner; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tools_subscreen_chooser_panel::add_tools_subscreen_chooser_panel; use crate::ui::components::top_panel::add_top_panel; use crate::ui::theme::{DashColors, Shape, Spacing, Typography}; +use crate::ui::{MessageType, RootScreenType}; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dash_sdk::dpp::identity::{ @@ -69,10 +70,6 @@ pub struct GroveSTARKScreen { proof_text: String, is_verifying: bool, verification_result: Option, - - // Error handling - gen_error_message: Option, - verify_error_message: Option, } impl GroveSTARKScreen { @@ -151,8 +148,6 @@ impl GroveSTARKScreen { proof_text: String::new(), is_verifying: false, verification_result: None, - gen_error_message: None, - verify_error_message: None, } } @@ -270,9 +265,10 @@ impl GroveSTARKScreen { fn generate_proof(&mut self, app_context: &AppContext) -> AppAction { if cfg!(debug_assertions) { - self.gen_error_message = Some( - "GroveSTARK proof generation requires a release build (cargo run --release)." - .to_string(), + MessageBanner::set_global( + app_context.egui_ctx(), + "GroveSTARK proof generation requires a release build (cargo run --release).", + MessageType::Error, ); self.is_generating = false; return AppAction::None; @@ -280,7 +276,6 @@ impl GroveSTARKScreen { // Reset any prior messages/results before starting a new generation self.is_generating = true; - self.gen_error_message = None; self.generated_proof = None; self.proof_size = None; self.generation_time = None; @@ -297,7 +292,11 @@ impl GroveSTARKScreen { id.clone() } None => { - self.gen_error_message = Some("No identity selected".to_string()); + MessageBanner::set_global( + app_context.egui_ctx(), + "No identity selected", + MessageType::Error, + ); self.is_generating = false; return AppAction::None; } @@ -306,7 +305,11 @@ impl GroveSTARKScreen { let selected_key = match &self.selected_key { Some(key) => key, None => { - self.gen_error_message = Some("No key selected".to_string()); + MessageBanner::set_global( + app_context.egui_ctx(), + "No key selected", + MessageType::Error, + ); self.is_generating = false; return AppAction::None; } @@ -322,7 +325,11 @@ impl GroveSTARKScreen { id.clone() } None => { - self.gen_error_message = Some("No contract selected".to_string()); + MessageBanner::set_global( + app_context.egui_ctx(), + "No contract selected", + MessageType::Error, + ); self.is_generating = false; return AppAction::None; } @@ -334,7 +341,11 @@ impl GroveSTARKScreen { doc_type.clone() } None => { - self.gen_error_message = Some("No document type selected".to_string()); + MessageBanner::set_global( + app_context.egui_ctx(), + "No document type selected", + MessageType::Error, + ); self.is_generating = false; return AppAction::None; } @@ -350,7 +361,11 @@ impl GroveSTARKScreen { id.clone() } None => { - self.gen_error_message = Some("No document selected".to_string()); + MessageBanner::set_global( + app_context.egui_ctx(), + "No document selected", + MessageType::Error, + ); self.is_generating = false; return AppAction::None; } @@ -374,20 +389,31 @@ impl GroveSTARKScreen { ) { Ok(Some((_, private_key_bytes))) => private_key_bytes, Ok(None) => { - self.gen_error_message = - Some("Private key not found in storage".to_string()); + MessageBanner::set_global( + app_context.egui_ctx(), + "Private key not found in storage", + MessageType::Error, + ); self.is_generating = false; return AppAction::None; } Err(e) => { - self.gen_error_message = Some(format!("Failed to get private key: {}", e)); + MessageBanner::set_global( + app_context.egui_ctx(), + format!("Failed to get private key: {}", e), + MessageType::Error, + ); self.is_generating = false; return AppAction::None; } } } None => { - self.gen_error_message = Some("Qualified identity not found".to_string()); + MessageBanner::set_global( + app_context.egui_ctx(), + "Qualified identity not found", + MessageType::Error, + ); self.is_generating = false; return AppAction::None; } @@ -416,18 +442,18 @@ impl GroveSTARKScreen { AppAction::BackendTask(task) } - fn verify_proof(&mut self, _app_context: &AppContext) -> AppAction { + fn verify_proof(&mut self, app_context: &AppContext) -> AppAction { if cfg!(debug_assertions) { - self.verify_error_message = Some( - "GroveSTARK proof verification requires a release build (cargo run --release)." - .to_string(), + MessageBanner::set_global( + app_context.egui_ctx(), + "GroveSTARK proof verification requires a release build (cargo run --release).", + MessageType::Error, ); self.is_verifying = false; return AppAction::None; } self.is_verifying = true; - self.verify_error_message = None; self.verification_result = None; // Clear any previous results // Parse the proof from pasted text @@ -448,7 +474,11 @@ impl GroveSTARKScreen { AppAction::BackendTask(task) } Err(e) => { - self.verify_error_message = Some(format!("Failed to parse proof: {}", e)); + MessageBanner::set_global( + app_context.egui_ctx(), + format!("Failed to parse proof: {}", e), + MessageType::Error, + ); self.is_verifying = false; AppAction::None } @@ -804,25 +834,7 @@ impl GroveSTARKScreen { return action; } - // Error Display - if let Some(error) = &self.gen_error_message { - let error_color = DashColors::ERROR; - let error = error.clone(); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new(format!("Error: {}", error)).color(error_color)); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.gen_error_message = None; - } - }); - }); - } + // Error display is handled by the global MessageBanner. // Success Display if let Some(_proof) = &self.generated_proof { @@ -884,25 +896,7 @@ impl GroveSTARKScreen { ui.separator(); - // Error Display (above the button) - if let Some(error) = &self.verify_error_message { - let error_color = DashColors::ERROR; - let error = error.clone(); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new(format!("Error: {}", error)).color(error_color)); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.verify_error_message = None; - } - }); - }); - } + // Error display is handled by the global MessageBanner. // Verify Button let can_verify = !self.proof_text.is_empty(); @@ -1000,13 +994,12 @@ impl ScreenLike for GroveSTARKScreen { self.refresh_contracts(&app_context); } - fn display_message(&mut self, message: &str, message_type: crate::ui::MessageType) { - // Only record errors and scope them to the active mode - if message_type == crate::ui::MessageType::Error { - match self.mode { - ProofMode::Generate => self.gen_error_message = Some(message.to_string()), - ProofMode::Verify => self.verify_error_message = Some(message.to_string()), - } + fn display_message(&mut self, _message: &str, message_type: crate::ui::MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!( + message_type, + crate::ui::MessageType::Error | crate::ui::MessageType::Warning + ) { self.is_generating = false; self.is_verifying = false; } @@ -1034,7 +1027,6 @@ impl ScreenLike for GroveSTARKScreen { self.generation_time = Some(std::time::Duration::from_millis( proof_data.metadata.generation_time_ms, )); - self.gen_error_message = None; } BackendTaskSuccessResult::VerifiedZKProof(is_valid, proof_data) => { self.is_verifying = false; @@ -1058,7 +1050,6 @@ impl ScreenLike for GroveSTARKScreen { if is_valid { "VALID" } else { "INVALID" } ), }); - self.verify_error_message = None; } _ => {} } diff --git a/src/ui/tools/masternode_list_diff_screen.rs b/src/ui/tools/masternode_list_diff_screen.rs index 41978be75..5e30a55b3 100644 --- a/src/ui/tools/masternode_list_diff_screen.rs +++ b/src/ui/tools/masternode_list_diff_screen.rs @@ -40,7 +40,7 @@ use dash_sdk::dpp::dashcore::{ }; use dash_sdk::dpp::prelude::CoreBlockHeight; use eframe::egui::{self, Context, ScrollArea, Ui}; -use egui::{Align, Color32, Frame, Layout, Margin, RichText, Stroke, TextEdit, Vec2}; +use egui::{Align, Frame, Layout, Margin, RichText, Stroke, TextEdit, Vec2}; use itertools::Itertools; use rfd::FileDialog; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; @@ -69,7 +69,6 @@ struct InputState { struct UiState { selected_tab: usize, show_popup_for_render_masternode_list_engine: bool, - message: Option<(String, MessageType)>, error: Option, } @@ -1163,7 +1162,6 @@ impl MasternodeListDiffScreen { self.task.queued_task = None; self.input.search_term = None; self.ui_state.error = None; - self.ui_state.message = None; } /// Clear all data except the oldest MNList diff starting from height 0 @@ -1207,7 +1205,6 @@ impl MasternodeListDiffScreen { self.selection.selected_quorum_hash_in_mnlist_diff = None; self.selection.selected_masternode_pro_tx_hash = None; self.data.qr_infos = Default::default(); - self.ui_state.message = None; // Clear chain lock signatures caches as these are independent of the retained base diff self.cache.chain_lock_sig_cache.clear(); self.cache.chain_lock_reversed_sig_cache.clear(); @@ -1557,38 +1554,6 @@ impl MasternodeListDiffScreen { action } - fn render_message_banner(&mut self, ui: &mut Ui) { - let Some((msg, msg_type)) = self.ui_state.message.clone() else { - return; - }; - - let dark_mode = ui.ctx().style().visuals.dark_mode; - let message_color = match msg_type { - MessageType::Error => DashColors::ERROR, - MessageType::Warning => DashColors::WARNING, - MessageType::Info => DashColors::text_primary(dark_mode), - // Dark green for success text - MessageType::Success => Color32::DARK_GREEN, - }; - ui.horizontal(|ui| { - Frame::new() - .fill(message_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, message_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new(msg).color(message_color)); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.ui_state.message = None; - } - }); - }); - }); - ui.add_space(10.0); - } - fn render_error_banner(&mut self, ui: &mut Ui) { let Some(error_msg) = self.ui_state.error.clone() else { return; @@ -4181,17 +4146,10 @@ impl MasternodeListDiffScreen { impl ScreenLike for MasternodeListDiffScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { - match message_type { - MessageType::Error | MessageType::Warning => { - self.task.pending = None; - self.ui_state.error = Some(message.to_string()); - } - MessageType::Success => { - self.ui_state.message = Some((message.to_string(), message_type)); - } - MessageType::Info => { - // Do not show transient info messages to avoid noisy black text banners. - } + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.task.pending = None; + self.ui_state.error = Some(message.to_string()); } } @@ -4433,7 +4391,6 @@ impl ScreenLike for MasternodeListDiffScreen { inner |= AppAction::BackendTask(task); } - self.render_message_banner(ui); self.render_error_banner(ui); self.render_pending_status(ui); diff --git a/src/ui/tools/platform_info_screen.rs b/src/ui/tools/platform_info_screen.rs index 87932457c..2237188a9 100644 --- a/src/ui/tools/platform_info_screen.rs +++ b/src/ui/tools/platform_info_screen.rs @@ -10,7 +10,7 @@ use crate::ui::theme::DashColors; use crate::ui::{MessageType, RootScreenType, ScreenLike}; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::version::PlatformVersion; -use eframe::egui::{self, Context, Frame, Margin, RichText, ScrollArea, Ui}; +use eframe::egui::{self, Context, ScrollArea, Ui}; use std::sync::Arc; pub struct PlatformInfoScreen { @@ -21,7 +21,6 @@ pub struct PlatformInfoScreen { current_result: Option, current_result_title: Option, active_tasks: std::collections::HashSet, - error_message: Option, } impl PlatformInfoScreen { @@ -34,7 +33,6 @@ impl PlatformInfoScreen { current_result: None, current_result_title: None, active_tasks: std::collections::HashSet::new(), - error_message: None, } } @@ -45,7 +43,6 @@ impl PlatformInfoScreen { ) -> AppAction { if !self.active_tasks.contains(task_name) { self.active_tasks.insert(task_name.to_string()); - self.error_message = None; let task = BackendTask::PlatformInfo(task_type); return AppAction::BackendTask(task); } @@ -129,27 +126,6 @@ impl PlatformInfoScreen { return; } - // Check for errors and display them in the results area - if let Some(error) = &self.error_message { - let error_color = DashColors::ERROR; - let error = error.clone(); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new(format!("Error: {}", error)).color(error_color)); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.error_message = None; - } - }); - }); - return; - } - // Display normal results if let Some(result) = &self.current_result { if let Some(title) = &self.current_result_title { @@ -176,7 +152,6 @@ impl ScreenLike for PlatformInfoScreen { self.current_result = None; self.current_result_title = None; self.active_tasks.clear(); - self.error_message = None; } fn refresh_on_arrival(&mut self) { @@ -247,10 +222,9 @@ impl ScreenLike for PlatformInfoScreen { action } - fn display_message(&mut self, message: &str, message_type: MessageType) { - if message_type == MessageType::Error { - self.error_message = Some(message.to_string()); - // Clear loading states for all tasks + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { self.active_tasks.clear(); } } @@ -295,7 +269,7 @@ impl ScreenLike for PlatformInfoScreen { self.current_result = Some(basic_info); self.current_result_title = Some("Basic Platform Information".to_string()); self.active_tasks.remove("basic_info"); - self.error_message = None; + // Error state cleared on success } PlatformInfoTaskResult::TextResult(text) => { // Find which task this result is for and set the title appropriately @@ -322,7 +296,7 @@ impl ScreenLike for PlatformInfoScreen { self.current_result = Some(text); self.current_result_title = Some(title); self.active_tasks.clear(); // Clear any remaining active tasks - self.error_message = None; + // Error state cleared on success } PlatformInfoTaskResult::AddressBalance { .. } => { // This result is handled by AddressBalanceScreen, not here diff --git a/src/ui/tools/transition_visualizer_screen.rs b/src/ui/tools/transition_visualizer_screen.rs index f890997f6..63c4b68ce 100644 --- a/src/ui/tools/transition_visualizer_screen.rs +++ b/src/ui/tools/transition_visualizer_screen.rs @@ -3,6 +3,7 @@ use crate::backend_task::BackendTask; use crate::backend_task::contract::ContractTask; use crate::context::AppContext; use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::message_banner::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tools_subscreen_chooser_panel::add_tools_subscreen_chooser_panel; use crate::ui::components::top_panel::add_top_panel; @@ -12,7 +13,6 @@ use crate::ui::{MessageType, RootScreenType, ScreenLike}; use base64::{Engine, engine::general_purpose::STANDARD}; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::platform_value::string_encoding::Encoding; -use dash_sdk::dpp::prelude::TimestampMillis; use dash_sdk::dpp::serialization::PlatformDeserializable; use dash_sdk::dpp::state_transition::StateTransition; use dash_sdk::platform::Identifier; @@ -20,13 +20,12 @@ use eframe::egui::{self, Color32, Context, ScrollArea, TextEdit, Ui, Window}; use egui::RichText; use serde_json::Value; use std::sync::Arc; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant}; #[derive(PartialEq)] enum TransitionBroadcastStatus { NotStarted, - Submitting(TimestampMillis), - Error(String, Instant), + Submitting, Complete(Instant), } @@ -34,7 +33,9 @@ pub struct TransitionVisualizerScreen { pub app_context: Arc, input_data: String, parsed_json: Option, + parse_error: Option<(String, Instant)>, broadcast_status: TransitionBroadcastStatus, + submit_banner: Option, show_contract_dialog: bool, selected_contract_id: Option, detected_contract_ids: Vec, @@ -47,7 +48,9 @@ impl TransitionVisualizerScreen { app_context: app_context.clone(), input_data: String::new(), parsed_json: None, + parse_error: None, broadcast_status: TransitionBroadcastStatus::NotStarted, + submit_banner: None, show_contract_dialog: false, selected_contract_id: None, detected_contract_ids: Vec::new(), @@ -84,10 +87,11 @@ impl TransitionVisualizerScreen { fn parse_input(&mut self) { // Clear previous parse results... self.parsed_json = None; + self.parse_error = None; self.detected_contract_ids.clear(); - // Reset the broadcast status so we no longer show old errors - // or "Submitting" states from a previous parse/broadcast. + // Reset the broadcast status so we no longer show old states + // from a previous parse/broadcast. self.broadcast_status = TransitionBroadcastStatus::NotStarted; // First, try to parse as comma-separated integers @@ -127,23 +131,21 @@ impl TransitionVisualizerScreen { } } Err(e) => { - self.broadcast_status = TransitionBroadcastStatus::Error( + self.parse_error = Some(( format!("Failed to serialize to JSON: {}", e), Instant::now(), - ); + )); } } } Err(e) => { - self.broadcast_status = TransitionBroadcastStatus::Error( - format!("Failed to parse: {}", e), - Instant::now(), - ); + self.parse_error = + Some((format!("Failed to parse: {}", e), Instant::now())); } } } Err(e) => { - self.broadcast_status = TransitionBroadcastStatus::Error(e, Instant::now()); + self.parse_error = Some((e, Instant::now())); } } } @@ -217,11 +219,8 @@ impl TransitionVisualizerScreen { ui.add_space(10.0); - // if we are NotStarted or in an Error state, show the button - if matches!( - self.broadcast_status, - TransitionBroadcastStatus::NotStarted | TransitionBroadcastStatus::Error(_, _) - ) { + // Show the button when not currently submitting or done + if matches!(self.broadcast_status, TransitionBroadcastStatus::NotStarted) { let mut new_style = (**ui.style()).clone(); new_style.spacing.button_padding = egui::vec2(10.0, 5.0); ui.set_style(new_style); @@ -235,11 +234,15 @@ impl TransitionVisualizerScreen { if ui.add(button).clicked() { // Mark as submitting - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.broadcast_status = TransitionBroadcastStatus::Submitting(now); + self.submit_banner.take_and_clear(); + let handle = MessageBanner::set_global( + ui.ctx(), + "Submitting transition...", + MessageType::Info, + ); + handle.with_elapsed(); + self.submit_banner = Some(handle); + self.broadcast_status = TransitionBroadcastStatus::Submitting; if let Some(json) = &self.parsed_json && let Ok(state_transition) = serde_json::from_str(json) @@ -258,61 +261,36 @@ impl TransitionVisualizerScreen { } }); - // Show status + // Show parse error if any (with fade-out) ui.add_space(5.0); - match &self.broadcast_status { - TransitionBroadcastStatus::NotStarted => {} - TransitionBroadcastStatus::Submitting(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed_seconds = now - start_time; - - let display_time = if elapsed_seconds < 60 { - format!( - "{} second{}", - elapsed_seconds, - if elapsed_seconds == 1 { "" } else { "s" } - ) + let mut clear_parse_error = false; + if let Some((msg, timestamp)) = &self.parse_error { + let elapsed = timestamp.elapsed(); + if elapsed < Duration::from_secs(8) { + let alpha = if elapsed > Duration::from_secs(6) { + let fade_progress = (8.0 - elapsed.as_secs_f32()) / 2.0; + (fade_progress * 255.0) as u8 } else { - let minutes = elapsed_seconds / 60; - let seconds = elapsed_seconds % 60; - format!( - "{} minute{} and {} second{}", - minutes, - if minutes == 1 { "" } else { "s" }, - seconds, - if seconds == 1 { "" } else { "s" } - ) + 255 }; - - ui.label(format!( - "Broadcasting... Time taken so far: {}", - display_time - )); + ui.colored_label( + Color32::from_rgba_premultiplied(139, 0, 0, alpha), // Dark red + format!("Error: {}", msg), + ); + ui.ctx().request_repaint_after(Duration::from_millis(100)); + } else { + clear_parse_error = true; } - TransitionBroadcastStatus::Error(msg, timestamp) => { - let elapsed = timestamp.elapsed(); - if elapsed < Duration::from_secs(8) { - // Calculate fade effect for last 2 seconds - let alpha = if elapsed > Duration::from_secs(6) { - let fade_progress = (8.0 - elapsed.as_secs_f32()) / 2.0; - (fade_progress * 255.0) as u8 - } else { - 255 - }; - ui.colored_label( - Color32::from_rgba_premultiplied(139, 0, 0, alpha), // Dark red - format!("Error: {}", msg), - ); + } + if clear_parse_error { + self.parse_error = None; + } - // Request repaint to update the fade effect - ui.ctx().request_repaint_after(Duration::from_millis(100)); - } else { - // Clear the error after 8 seconds - self.broadcast_status = TransitionBroadcastStatus::NotStarted; - } + // Show broadcast status + match &self.broadcast_status { + TransitionBroadcastStatus::NotStarted => {} + TransitionBroadcastStatus::Submitting => { + // Elapsed time is shown in the global banner } TransitionBroadcastStatus::Complete(timestamp) => { let elapsed = timestamp.elapsed(); @@ -403,24 +381,20 @@ impl TransitionVisualizerScreen { } impl ScreenLike for TransitionVisualizerScreen { - fn display_message(&mut self, message: &str, message_type: MessageType) { + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. match message_type { MessageType::Success => { - // Only update broadcast status if we're actually broadcasting - if matches!( - self.broadcast_status, - TransitionBroadcastStatus::Submitting(_) - ) { + if matches!(self.broadcast_status, TransitionBroadcastStatus::Submitting) { + self.submit_banner.take_and_clear(); self.broadcast_status = TransitionBroadcastStatus::Complete(Instant::now()); } } MessageType::Error | MessageType::Warning => { - self.broadcast_status = - TransitionBroadcastStatus::Error(message.to_string(), Instant::now()); - } - MessageType::Info => { - // Could do nothing or handle info + self.submit_banner.take_and_clear(); + self.broadcast_status = TransitionBroadcastStatus::NotStarted; } + MessageType::Info => {} } } diff --git a/src/ui/wallets/asset_lock_detail_screen.rs b/src/ui/wallets/asset_lock_detail_screen.rs index 53abb04a3..5f7f943d9 100644 --- a/src/ui/wallets/asset_lock_detail_screen.rs +++ b/src/ui/wallets/asset_lock_detail_screen.rs @@ -1,13 +1,13 @@ use crate::app::AppAction; use crate::context::AppContext; use crate::model::wallet::Wallet; +use crate::ui::components::MessageBanner; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; use crate::ui::theme::DashColors; use crate::ui::{MessageType, RootScreenType, ScreenLike}; -use chrono::{DateTime, Utc}; use dash_sdk::dashcore_rpc::dashcore::{Address, InstantLock, Transaction}; use dash_sdk::dpp::fee::Credits; use dash_sdk::dpp::prelude::AssetLockProof; @@ -19,11 +19,9 @@ pub struct AssetLockDetailScreen { pub wallet_seed_hash: [u8; 32], pub asset_lock_index: usize, pub app_context: Arc, - message: Option<(String, MessageType, DateTime)>, wallet: Option>>, wallet_password: String, show_password: bool, - error_message: Option, show_private_key_popup: bool, private_key_wif: Option, } @@ -47,11 +45,9 @@ impl AssetLockDetailScreen { wallet_seed_hash, asset_lock_index, app_context: app_context.clone(), - message: None, wallet, wallet_password: String::new(), show_password: false, - error_message: None, show_private_key_popup: false, private_key_wif: None, } @@ -189,7 +185,7 @@ impl AssetLockDetailScreen { ui.label("Asset Lock Proof (hex):"); if ui.small_button("Copy").clicked() { ui.ctx().copy_text(proof_hex.clone()); - self.display_message("Asset lock proof copied to clipboard", MessageType::Success); + MessageBanner::set_global(ui.ctx(), "Asset lock proof copied to clipboard", MessageType::Success); } }); ui.add_space(5.0); @@ -235,7 +231,7 @@ impl AssetLockDetailScreen { self.show_private_key_popup = true; } Err(e) => { - self.display_message(&format!("Error retrieving private key: {}", e), MessageType::Error); + MessageBanner::set_global(ui.ctx(), format!("Error retrieving private key: {}", e), MessageType::Error); } } } @@ -262,17 +258,6 @@ impl AssetLockDetailScreen { }); } } - - fn check_message_expiration(&mut self) { - if let Some((_, _, timestamp)) = &self.message { - let now = Utc::now(); - let elapsed = now.signed_duration_since(*timestamp); - - if elapsed.num_seconds() >= 10 { - self.message = None; - } - } - } } impl ScreenWithWalletUnlock for AssetLockDetailScreen { @@ -296,14 +281,6 @@ impl ScreenWithWalletUnlock for AssetLockDetailScreen { &mut self.show_password } - fn set_error_message(&mut self, error_message: Option) { - self.error_message = error_message; - } - - fn error_message(&self) -> Option<&String> { - self.error_message.as_ref() - } - fn app_context(&self) -> Arc { self.app_context.clone() } @@ -311,8 +288,6 @@ impl ScreenWithWalletUnlock for AssetLockDetailScreen { impl ScreenLike for AssetLockDetailScreen { fn ui(&mut self, ctx: &Context) -> AppAction { - self.check_message_expiration(); - let mut action = add_top_panel( ctx, &self.app_context, @@ -360,28 +335,7 @@ impl ScreenLike for AssetLockDetailScreen { self.render_asset_lock_info(ui); }); - // Display messages - if let Some((message, message_type, timestamp)) = &self.message { - let message_color = match message_type { - MessageType::Error => egui::Color32::DARK_RED, - MessageType::Warning => DashColors::warning_color(dark_mode), - MessageType::Info => DashColors::text_primary(dark_mode), - MessageType::Success => egui::Color32::DARK_GREEN, - }; - - ui.add_space(25.0); - ui.horizontal(|ui| { - ui.add_space(10.0); - - let now = Utc::now(); - let elapsed = now.signed_duration_since(*timestamp); - let remaining = (10 - elapsed.num_seconds()).max(0); - - let full_msg = format!("{} ({}s)", message, remaining); - ui.label(egui::RichText::new(full_msg).color(message_color)); - }); - ui.add_space(2.0); - } + // Message display is handled by the global MessageBanner inner_action }); @@ -420,7 +374,7 @@ impl ScreenLike for AssetLockDetailScreen { ui.horizontal(|ui| { if ui.button("Copy").clicked() { ui.ctx().copy_text(wif.clone()); - self.display_message("Private key copied to clipboard", MessageType::Success); + MessageBanner::set_global(ctx, "Private key copied to clipboard", MessageType::Success); } if ui.button("Close").clicked() { self.show_private_key_popup = false; @@ -435,8 +389,8 @@ impl ScreenLike for AssetLockDetailScreen { action } - fn display_message(&mut self, message: &str, message_type: MessageType) { - self.message = Some((message.to_string(), message_type, Utc::now())); + fn display_message(&mut self, _message: &str, _message_type: MessageType) { + // Error/success display is handled by the global MessageBanner. } fn refresh_on_arrival(&mut self) {} diff --git a/src/ui/wallets/create_asset_lock_screen.rs b/src/ui/wallets/create_asset_lock_screen.rs index b0d28d310..4f6b3aaba 100644 --- a/src/ui/wallets/create_asset_lock_screen.rs +++ b/src/ui/wallets/create_asset_lock_screen.rs @@ -6,6 +6,7 @@ use crate::model::amount::Amount; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; use crate::ui::components::Component; +use crate::ui::components::MessageBanner; use crate::ui::components::amount_input::AmountInput; use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::left_panel::add_left_panel; @@ -15,7 +16,6 @@ use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; use crate::ui::identities::funding_common::{self, WalletFundedScreenStep, generate_qr_code_image}; use crate::ui::theme::DashColors; use crate::ui::{MessageType, RootScreenType, ScreenLike}; -use chrono::{DateTime, Utc}; use dash_sdk::dashcore_rpc::RpcApi; use dash_sdk::dashcore_rpc::dashcore::{Address, OutPoint, TxOut}; use eframe::egui::{self, Context, Ui}; @@ -35,11 +35,8 @@ pub struct CreateAssetLockScreen { pub wallet: Arc>, selected_wallet: Option>>, pub app_context: Arc, - message: Option<(String, MessageType, DateTime)>, wallet_password: String, show_password: bool, - error_message: Option, - // Asset lock creation fields step: Arc>, amount_input: Option, @@ -78,10 +75,8 @@ impl CreateAssetLockScreen { wallet, selected_wallet, app_context: app_context.clone(), - message: None, wallet_password: String::new(), show_password: false, - error_message: None, step: Arc::new(RwLock::new(WalletFundedScreenStep::WaitingOnFunds)), amount_input: Some( AmountInput::new(Amount::new_dash(0.5)) @@ -182,23 +177,16 @@ impl CreateAssetLockScreen { if ui.button("Copy Address").clicked() { ui.ctx().copy_text(dash_uri.clone()); - self.display_message("Address copied to clipboard", MessageType::Success); + MessageBanner::set_global( + ui.ctx(), + "Address copied to clipboard", + MessageType::Success, + ); } Ok(()) } - fn check_message_expiration(&mut self) { - if let Some((_, _, timestamp)) = &self.message { - let now = Utc::now(); - let elapsed = now.signed_duration_since(*timestamp); - - if elapsed.num_seconds() >= 10 { - self.message = None; - } - } - } - fn show_success(&mut self, ui: &mut Ui) -> AppAction { let mut action = AppAction::None; @@ -251,7 +239,6 @@ impl CreateAssetLockScreen { self.funding_utxo = None; self.core_has_funding_address = None; self.asset_lock_tx_id = None; - self.error_message = None; self.show_advanced_options = false; *self.step.write().unwrap() = WalletFundedScreenStep::WaitingOnFunds; } @@ -284,14 +271,6 @@ impl ScreenWithWalletUnlock for CreateAssetLockScreen { &mut self.show_password } - fn set_error_message(&mut self, error_message: Option) { - self.error_message = error_message; - } - - fn error_message(&self) -> Option<&String> { - self.error_message.as_ref() - } - fn app_context(&self) -> Arc { self.app_context.clone() } @@ -299,8 +278,6 @@ impl ScreenWithWalletUnlock for CreateAssetLockScreen { impl ScreenLike for CreateAssetLockScreen { fn ui(&mut self, ctx: &Context) -> AppAction { - self.check_message_expiration(); - let wallet_name = self .wallet .read() @@ -609,16 +586,11 @@ impl ScreenLike for CreateAssetLockScreen { egui::Layout::top_down(egui::Align::Min).with_cross_align(egui::Align::Center), |ui| { if let Err(e) = self.render_qr_code(ui) { - self.error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } ui.add_space(20.0); - if let Some(error_message) = self.error_message.as_ref() { - ui.colored_label(egui::Color32::DARK_RED, error_message); - ui.add_space(20.0); - } - match step { WalletFundedScreenStep::WaitingOnFunds => { ui.heading(RichText::new("Waiting for funds...").color(DashColors::text_primary(dark_mode))); @@ -652,21 +624,21 @@ impl ScreenLike for CreateAssetLockScreen { CoreTask::CreateTopUpAssetLock(self.wallet.clone(), credits, identity_index, self.top_up_index) )) } else { - self.error_message = Some("Selected identity has no wallet index".to_string()); + MessageBanner::set_global(ui.ctx(), "Selected identity has no wallet index", MessageType::Error); AppAction::None } } else { - self.error_message = Some("No identity selected for top-up".to_string()); + MessageBanner::set_global(ui.ctx(), "No identity selected for top-up", MessageType::Error); AppAction::None } } None => { - self.error_message = Some("No purpose selected".to_string()); + MessageBanner::set_global(ui.ctx(), "No purpose selected", MessageType::Error); AppAction::None } } } else { - self.error_message = Some("No amount specified".to_string()); + MessageBanner::set_global(ui.ctx(), "No amount specified", MessageType::Error); AppAction::None } } @@ -690,28 +662,7 @@ impl ScreenLike for CreateAssetLockScreen { } }); - // Display messages - if let Some((message, message_type, timestamp)) = &self.message { - let message_color = match message_type { - MessageType::Error => egui::Color32::DARK_RED, - MessageType::Warning => DashColors::warning_color(dark_mode), - MessageType::Info => DashColors::text_primary(dark_mode), - MessageType::Success => egui::Color32::DARK_GREEN, - }; - - ui.add_space(25.0); - ui.horizontal(|ui| { - ui.add_space(10.0); - - let now = Utc::now(); - let elapsed = now.signed_duration_since(*timestamp); - let remaining = (10 - elapsed.num_seconds()).max(0); - - let full_msg = format!("{} ({}s)", message, remaining); - ui.label(egui::RichText::new(full_msg).color(message_color)); - }); - ui.add_space(2.0); - } + // Message display is handled by the global MessageBanner inner_action }); @@ -719,8 +670,8 @@ impl ScreenLike for CreateAssetLockScreen { action } - fn display_message(&mut self, message: &str, message_type: MessageType) { - self.message = Some((message.to_string(), message_type, Utc::now())); + fn display_message(&mut self, _message: &str, _message_type: MessageType) { + // Error/success display is handled by the global MessageBanner. } fn refresh_on_arrival(&mut self) { @@ -769,7 +720,8 @@ impl ScreenLike for CreateAssetLockScreen { let mut step = self.step.write().unwrap(); *step = WalletFundedScreenStep::Success; drop(step); - self.display_message( + MessageBanner::set_global( + self.app_context.egui_ctx(), "Asset lock created successfully!", MessageType::Success, ); @@ -784,7 +736,8 @@ impl ScreenLike for CreateAssetLockScreen { let mut step = self.step.write().unwrap(); *step = WalletFundedScreenStep::Success; drop(step); - self.display_message( + MessageBanner::set_global( + self.app_context.egui_ctx(), "Asset lock created successfully!", MessageType::Success, ); @@ -806,7 +759,8 @@ impl ScreenLike for CreateAssetLockScreen { let mut step = self.step.write().unwrap(); *step = WalletFundedScreenStep::Success; drop(step); - self.display_message( + MessageBanner::set_global( + self.app_context.egui_ctx(), "Asset lock created successfully!", MessageType::Success, ); @@ -821,7 +775,8 @@ impl ScreenLike for CreateAssetLockScreen { let mut step = self.step.write().unwrap(); *step = WalletFundedScreenStep::Success; drop(step); - self.display_message( + MessageBanner::set_global( + self.app_context.egui_ctx(), "Asset lock created successfully!", MessageType::Success, ); diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index 51ec1f309..ff46ad922 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -14,6 +14,7 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::theme::DashColors; use crate::ui::{MessageType, RootScreenType, ScreenLike}; use dash_sdk::dashcore_rpc::dashcore::Address; @@ -34,7 +35,6 @@ use eframe::egui::{self, Context, RichText, Ui}; use egui::{Color32, Frame, Margin}; use std::collections::BTreeMap; use std::sync::{Arc, RwLock}; -use std::time::{SystemTime, UNIX_EPOCH}; /// Maximum number of platform address inputs allowed per state transition const MAX_PLATFORM_INPUTS: usize = 16; @@ -286,12 +286,12 @@ pub enum SourceSelection { #[derive(Debug, Clone, PartialEq)] pub enum SendStatus { NotStarted, - /// Waiting for result, stores the start time in seconds since epoch - WaitingForResult(u64), + /// Waiting for result + WaitingForResult, /// Successfully completed with a success message Complete(String), - /// Error occurred - Error(String), + /// Error occurred (message displayed by global MessageBanner) + Error, } /// Fee strategy for platform transfers @@ -392,10 +392,11 @@ pub struct WalletSendScreen { // State send_status: SendStatus, + send_banner: Option, // Wallet unlock wallet_unlock_popup: WalletUnlockPopup, - error_message: Option, + wallet_open_attempted: bool, } impl WalletSendScreen { @@ -420,8 +421,9 @@ impl WalletSendScreen { fee_strategy: PlatformFeeStrategy::default(), subtract_fee: false, send_status: SendStatus::NotStarted, + send_banner: None, wallet_unlock_popup: WalletUnlockPopup::new(), - error_message: None, + wallet_open_attempted: false, } } @@ -485,15 +487,8 @@ impl WalletSendScreen { self.send_status = SendStatus::NotStarted; } - fn now_epoch_secs() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs() - } - fn mark_sending(&mut self) { - self.send_status = SendStatus::WaitingForResult(Self::now_epoch_secs()); + self.send_status = SendStatus::WaitingForResult; } fn format_dash(amount_duffs: u64) -> String { @@ -706,6 +701,16 @@ impl WalletSendScreen { } } + /// Clear the current send banner and show a new "Sending transaction..." progress banner. + /// + /// Called before dispatching any send backend task so the elapsed counter always starts fresh. + fn set_send_progress_banner(&mut self, ctx: &Context) { + self.send_banner.take_and_clear(); + let handle = MessageBanner::set_global(ctx, "Sending transaction...", MessageType::Info); + handle.with_elapsed(); + self.send_banner = Some(handle); + } + /// Validate and execute the send based on detected types fn validate_and_send(&mut self) -> Result { let wallet = self.selected_wallet.as_ref().ok_or("No wallet selected")?; @@ -1113,8 +1118,11 @@ impl WalletSendScreen { return true; }; - if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + if !self.wallet_open_attempted { + if let Err(e) = try_open_wallet_no_password(wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + } + self.wallet_open_attempted = true; } if wallet_needs_unlock(wallet) { ui.add_space(10.0); @@ -1133,28 +1141,7 @@ impl WalletSendScreen { true } - fn format_elapsed_time(start_time: u64) -> String { - let elapsed_seconds = Self::now_epoch_secs().saturating_sub(start_time); - if elapsed_seconds < 60 { - format!( - "{} second{}", - elapsed_seconds, - if elapsed_seconds == 1 { "" } else { "s" } - ) - } else { - let minutes = elapsed_seconds / 60; - let seconds = elapsed_seconds % 60; - format!( - "{} minute{} {} second{}", - minutes, - if minutes == 1 { "" } else { "s" }, - seconds, - if seconds == 1 { "" } else { "s" } - ) - } - } - - fn render_send_status(&mut self, ui: &mut Ui, dark_mode: bool) -> Option { + fn render_send_status(&mut self, ui: &mut Ui) -> Option { match self.send_status.clone() { SendStatus::Complete(message) => { let mut action = AppAction::None; @@ -1176,43 +1163,21 @@ impl WalletSendScreen { }); Some(action) } - SendStatus::WaitingForResult(start_time) => { + SendStatus::WaitingForResult => { ui.vertical_centered(|ui| { ui.add_space(100.0); ui.add(egui::Spinner::new().size(40.0)); ui.add_space(20.0); ui.heading("Sending..."); - ui.add_space(10.0); - ui.label( - RichText::new(format!( - "Time elapsed: {}", - Self::format_elapsed_time(start_time) - )) - .color(DashColors::text_secondary(dark_mode)), - ); ui.add_space(100.0); }); Some(AppAction::None) } - SendStatus::Error(error_msg) => { - let mut dismiss = false; - ui.horizontal(|ui| { - Frame::new() - .fill(DashColors::ERROR.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, DashColors::ERROR)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new(&error_msg).color(DashColors::ERROR)); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - dismiss = true; - } - }); - }); - }); - if dismiss { + SendStatus::Error => { + // Error message is displayed by the global MessageBanner. + // Show a dismiss/retry option. + ui.add_space(10.0); + if ui.button("Dismiss").clicked() { self.send_status = SendStatus::NotStarted; } ui.add_space(10.0); @@ -1321,7 +1286,7 @@ impl WalletSendScreen { return Err("Invalid shielded address".to_string()); }; - self.send_status = SendStatus::WaitingForResult(Self::now_epoch_secs()); + self.send_status = SendStatus::WaitingForResult; Ok(AppAction::BackendTask( crate::backend_task::BackendTask::ShieldedTask( crate::backend_task::shielded::ShieldedTask::ShieldedTransfer { @@ -1348,7 +1313,7 @@ impl WalletSendScreen { let (platform_addr, _) = PlatformAddress::from_bech32m_string(address_str) .map_err(|e| format!("Invalid platform address: {e}"))?; - self.send_status = SendStatus::WaitingForResult(Self::now_epoch_secs()); + self.send_status = SendStatus::WaitingForResult; Ok(AppAction::BackendTask( crate::backend_task::BackendTask::ShieldedTask( crate::backend_task::shielded::ShieldedTask::UnshieldCredits { @@ -1848,7 +1813,7 @@ impl WalletSendScreen { let has_amount = self.amount.as_ref().map(|a| a.value() > 0).unwrap_or(false); let has_source = self.selected_source.is_some(); - let is_sending = matches!(self.send_status, SendStatus::WaitingForResult(_)); + let is_sending = matches!(self.send_status, SendStatus::WaitingForResult); let can_send = wallet_open && !is_sending && has_destination && has_amount && has_source; ui.horizontal(|ui| { @@ -1876,10 +1841,12 @@ impl WalletSendScreen { if ui.add_enabled(can_send, send_button).clicked() { match self.validate_and_send() { Ok(send_action) => { + self.set_send_progress_banner(ui.ctx()); action = send_action; } Err(e) => { - self.display_message(&e, MessageType::Error); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + self.send_status = SendStatus::Error; } } } @@ -2410,7 +2377,7 @@ impl WalletSendScreen { .as_ref() .is_some_and(|w| w.read().map(|g| g.is_open()).unwrap_or(false)); - let is_sending = matches!(self.send_status, SendStatus::WaitingForResult(_)); + let is_sending = matches!(self.send_status, SendStatus::WaitingForResult); // Check if we have valid inputs based on source type let has_valid_inputs = match self.advanced_source_type { @@ -2455,10 +2422,12 @@ impl WalletSendScreen { if ui.add_enabled(can_send, send_button).clicked() { match self.validate_and_send_advanced() { Ok(send_action) => { + self.set_send_progress_banner(ui.ctx()); action = send_action; } Err(e) => { - self.display_message(&e, MessageType::Error); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + self.send_status = SendStatus::Error; } } } @@ -2792,7 +2761,7 @@ impl ScreenLike for WalletSendScreen { let mut inner_action = AppAction::None; let dark_mode = ui.ctx().style().visuals.dark_mode; - if let Some(status_action) = self.render_send_status(ui, dark_mode) { + if let Some(status_action) = self.render_send_status(ui) { return status_action; } @@ -2839,11 +2808,14 @@ impl ScreenLike for WalletSendScreen { } fn display_message(&mut self, message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. match message_type { MessageType::Error | MessageType::Warning => { - self.send_status = SendStatus::Error(message.to_string()); + self.send_banner.take_and_clear(); + self.send_status = SendStatus::Error; } MessageType::Success => { + self.send_banner.take_and_clear(); self.send_status = SendStatus::Complete(message.to_string()); } MessageType::Info => { @@ -2856,6 +2828,7 @@ impl ScreenLike for WalletSendScreen { &mut self, backend_task_success_result: crate::backend_task::BackendTaskSuccessResult, ) { + self.send_banner.take_and_clear(); match backend_task_success_result { crate::backend_task::BackendTaskSuccessResult::WalletPayment { txid: _, diff --git a/src/ui/wallets/single_key_send_screen.rs b/src/ui/wallets/single_key_send_screen.rs index 9528d9020..69b3e4edc 100644 --- a/src/ui/wallets/single_key_send_screen.rs +++ b/src/ui/wallets/single_key_send_screen.rs @@ -6,13 +6,13 @@ use crate::backend_task::core::{CoreTask, PaymentRecipient, WalletPaymentRequest use crate::context::AppContext; use crate::model::amount::{Amount, DASH_DECIMAL_PLACES}; use crate::model::wallet::single_key::SingleKeyWallet; +use crate::ui::components::MessageBanner; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; use crate::ui::theme::DashColors; use crate::ui::{MessageType, RootScreenType, ScreenLike}; -use chrono::{DateTime, Utc}; -use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::fee::FeeLevel; +use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::fee::FeeRate; use eframe::egui::{self, Context, RichText, Ui}; use egui::{Color32, Frame, Margin}; use std::sync::{Arc, RwLock}; @@ -60,12 +60,10 @@ pub struct SingleKeyWalletSendScreen { // State sending: bool, - message: Option<(String, MessageType, DateTime)>, // Wallet unlock wallet_password: String, show_password: bool, - error_message: Option, // Fee confirmation dialog fee_dialog: FeeConfirmationDialog, @@ -84,10 +82,8 @@ impl SingleKeyWalletSendScreen { subtract_fee: false, memo: String::new(), sending: false, - message: None, wallet_password: String::new(), show_password: false, - error_message: None, fee_dialog: FeeConfirmationDialog::default(), show_advanced_options: false, } @@ -152,7 +148,7 @@ impl SingleKeyWalletSendScreen { // No valid amounts entered yet, show estimate for minimum tx let output_count = self.recipients.len().max(1) + 1; let estimated_size = Self::estimate_p2pkh_tx_size(1, output_count); - let fee = FeeLevel::Normal.fee_rate().calculate_fee(estimated_size); + let fee = FeeRate::normal().calculate_fee(estimated_size); return Some((fee, 1, estimated_size)); } @@ -172,7 +168,7 @@ impl SingleKeyWalletSendScreen { // Recalculate fee with current input count let current_size = Self::estimate_p2pkh_tx_size(selected_count, output_count); - let current_fee = FeeLevel::Normal.fee_rate().calculate_fee(current_size); + let current_fee = FeeRate::normal().calculate_fee(current_size); if selected_total >= total_output + current_fee { return Some((current_fee, selected_count, current_size)); @@ -181,7 +177,7 @@ impl SingleKeyWalletSendScreen { // Not enough funds - show what we'd need with all UTXOs let estimated_size = Self::estimate_p2pkh_tx_size(selected_count, output_count); - let fee = FeeLevel::Normal.fee_rate().calculate_fee(estimated_size); + let fee = FeeRate::normal().calculate_fee(estimated_size); Some((fee, selected_count, estimated_size)) } @@ -783,27 +779,27 @@ impl SingleKeyWalletSendScreen { Ok(mut wallet_guard) => { match wallet_guard.open(&self.wallet_password) { Ok(_) => { - self.error_message = None; self.wallet_password.clear(); } Err(e) => { - self.error_message = - Some(format!("Failed to unlock: {}", e)); + MessageBanner::set_global( + ui.ctx(), + format!("Failed to unlock: {}", e), + MessageType::Error, + ); } } } Err(_) => { - self.error_message = - Some("Wallet lock error, please try again".to_string()); + MessageBanner::set_global( + ui.ctx(), + "Wallet lock error, please try again", + MessageType::Error, + ); } } } }); - - if let Some(error) = &self.error_message { - ui.add_space(5.0); - ui.label(RichText::new(error).color(DashColors::ERROR).size(12.0)); - } }); AppAction::None @@ -847,7 +843,7 @@ impl SingleKeyWalletSendScreen { action = send_action; } Err(e) => { - self.display_message(&e, MessageType::Error); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } } } @@ -855,10 +851,6 @@ impl SingleKeyWalletSendScreen { action } - - fn dismiss_message(&mut self) { - self.message = None; - } } impl ScreenLike for SingleKeyWalletSendScreen { @@ -880,38 +872,7 @@ impl ScreenLike for SingleKeyWalletSendScreen { let mut inner_action = AppAction::None; let dark_mode = ui.ctx().style().visuals.dark_mode; - // Display messages at the top - let mut should_dismiss = false; - if let Some((message, message_type, _)) = &self.message { - let message = message.clone(); - let message_color = match message_type { - MessageType::Error => DashColors::ERROR, - MessageType::Warning => DashColors::WARNING, - MessageType::Info => DashColors::text_primary(dark_mode), - MessageType::Success => Color32::DARK_GREEN, - }; - - ui.horizontal(|ui| { - Frame::new() - .fill(message_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, message_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new(&message).color(message_color)); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - should_dismiss = true; - } - }); - }); - }); - ui.add_space(10.0); - } - if should_dismiss { - self.dismiss_message(); - } + // Message display is handled by the global MessageBanner. egui::ScrollArea::vertical() .auto_shrink([true; 2]) @@ -969,6 +930,9 @@ impl ScreenLike for SingleKeyWalletSendScreen { } fn display_message(&mut self, message: &str, message_type: MessageType) { + // Error/success display is handled by the global MessageBanner. + // Only side-effects are preserved here. + // Check for success messages to reset sending state if message.contains("Sent") || message.contains("TxID") { self.sending = false; @@ -976,17 +940,14 @@ impl ScreenLike for SingleKeyWalletSendScreen { } // Check for min relay fee error and show confirmation dialog - if message_type == MessageType::Error + if matches!(message_type, MessageType::Error | MessageType::Warning) && let Some(required_fee) = Self::parse_min_relay_fee_error(message) { // Show the fee confirmation dialog instead of the error message self.fee_dialog.required_fee = required_fee; self.fee_dialog.is_open = true; // Keep sending state true until user confirms or cancels - return; } - - self.message = Some((message.to_string(), message_type, Utc::now())); } fn display_task_result( @@ -1023,7 +984,8 @@ impl ScreenLike for SingleKeyWalletSendScreen { txid ) }; - self.display_message(&msg, MessageType::Success); + MessageBanner::set_global(self.app_context.egui_ctx(), &msg, MessageType::Success); + self.fee_dialog.pending_request = None; // Clear the form after successful send self.recipients = vec![SendRecipient::new(0)]; diff --git a/src/ui/wallets/wallets_screen/address_table.rs b/src/ui/wallets/wallets_screen/address_table.rs index 92d35573e..57bd464fe 100644 --- a/src/ui/wallets/wallets_screen/address_table.rs +++ b/src/ui/wallets/wallets_screen/address_table.rs @@ -1,7 +1,8 @@ use crate::app::AppAction; use crate::model::wallet::{DerivationPathHelpers, DerivationPathReference}; +use crate::ui::MessageType; +use crate::ui::components::message_banner::MessageBanner; use crate::ui::wallets::account_summary::{AccountCategory, categorize_account_path}; -use crate::ui::{MessageType, ScreenLike}; use dash_sdk::dashcore_rpc::dashcore::{Address, Network}; use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; use dash_sdk::dpp::key_wallet::bip32::{ChildNumber, DerivationPath}; @@ -448,7 +449,13 @@ impl WalletsBalancesScreen { self.private_key_dialog.private_key_wif = key; self.private_key_dialog.show_key = false; } - Err(err) => self.display_message(&err, MessageType::Error), + Err(err) => { + MessageBanner::set_global( + self.app_context.egui_ctx(), + &err, + MessageType::Error, + ); + } } } } diff --git a/src/ui/wallets/wallets_screen/dialogs.rs b/src/ui/wallets/wallets_screen/dialogs.rs index acf0c2a07..f5c2d4dee 100644 --- a/src/ui/wallets/wallets_screen/dialogs.rs +++ b/src/ui/wallets/wallets_screen/dialogs.rs @@ -5,6 +5,7 @@ use crate::backend_task::wallet::WalletTask; use crate::model::amount::Amount; use crate::model::wallet::{DerivationPathHelpers, Wallet}; use crate::ui::MessageType; +use crate::ui::components::MessageBanner; use crate::ui::components::amount_input::AmountInput; use crate::ui::components::component_trait::{Component, ComponentResponse}; use crate::ui::helpers::copy_text_to_clipboard; @@ -1238,7 +1239,11 @@ impl WalletsBalancesScreen { pub(super) fn open_mine_dialog(&mut self) { let Some(wallet) = self.selected_wallet.clone() else { - self.set_message("Select a wallet first".to_string(), MessageType::Error); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Select a wallet first", + MessageType::Error, + ); return; }; diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index ce5bb58a0..d60f0d243 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -17,6 +17,7 @@ use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{WalletUnlockPopup, WalletUnlockResult}; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::helpers::copy_text_to_clipboard; use crate::ui::theme::DashColors; use crate::ui::wallets::account_summary::{ @@ -81,7 +82,6 @@ pub struct WalletsBalancesScreen { selected_wallet: Option>>, selected_single_key_wallet: Option>>, pub(crate) app_context: Arc, - message: Option<(String, MessageType, DateTime)>, sort_column: SortColumn, sort_order: SortOrder, refreshing: bool, @@ -91,7 +91,6 @@ pub struct WalletsBalancesScreen { show_sk_unlock_dialog: bool, sk_wallet_password: String, sk_show_password: bool, - sk_error_message: Option, remove_wallet_dialog: Option, pending_wallet_removal: Option, pending_wallet_removal_alias: Option, @@ -110,6 +109,8 @@ pub struct WalletsBalancesScreen { pending_refresh_mode: RefreshMode, /// Whether we should search for asset locks after wallet is unlocked pending_asset_lock_search_after_unlock: bool, + /// Banner handle for asset lock search progress + asset_lock_search_banner: Option, /// Current page for single key wallet UTXO pagination (0-indexed) utxo_page: usize, /// Selected refresh mode (only shown in dev mode) @@ -187,7 +188,6 @@ impl WalletsBalancesScreen { selected_wallet, selected_single_key_wallet, app_context: app_context.clone(), - message: None, sort_column: SortColumn::Index, sort_order: SortOrder::Ascending, refreshing: false, @@ -197,7 +197,6 @@ impl WalletsBalancesScreen { show_sk_unlock_dialog: false, sk_wallet_password: String::new(), sk_show_password: false, - sk_error_message: None, remove_wallet_dialog: None, pending_wallet_removal: None, pending_wallet_removal_alias: None, @@ -212,6 +211,7 @@ impl WalletsBalancesScreen { pending_refresh_after_unlock: false, pending_refresh_mode: RefreshMode::default(), pending_asset_lock_search_after_unlock: false, + asset_lock_search_banner: None, utxo_page: 0, refresh_mode: RefreshMode::default(), selected_tab: WalletViewTab::default(), @@ -356,14 +356,22 @@ impl WalletsBalancesScreen { match result { Ok(address) => { let message = format!("Added new receiving address: {}", address); - self.display_message(&message, MessageType::Success); + MessageBanner::set_global( + self.app_context.egui_ctx(), + &message, + MessageType::Success, + ); } Err(e) => { - self.display_message(&e, MessageType::Error); + MessageBanner::set_global(self.app_context.egui_ctx(), &e, MessageType::Error); } } } else { - self.display_message("No wallet selected", MessageType::Error); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "No wallet selected", + MessageType::Error, + ); } } @@ -581,8 +589,9 @@ impl WalletsBalancesScreen { .db .remove_single_key_wallet(&key_hash, self.app_context.network) { - self.display_message( - &format!("Failed to remove: {}", e), + MessageBanner::set_global( + ui.ctx(), + format!("Failed to remove: {}", e), MessageType::Error, ); } else { @@ -593,7 +602,11 @@ impl WalletsBalancesScreen { self.selected_single_key_wallet = None; // Clear persisted selection in AppContext and database self.persist_selected_single_key_hash(None); - self.display_message("Wallet removed", MessageType::Success); + MessageBanner::set_global( + ui.ctx(), + "Wallet removed", + MessageType::Success, + ); } } @@ -742,14 +755,16 @@ impl WalletsBalancesScreen { self.wallet_unlock_popup.close(); self.refreshing = false; - self.display_message( - &format!("Removed wallet \"{}\" successfully", alias), + MessageBanner::set_global( + self.app_context.egui_ctx(), + format!("Removed wallet \"{}\" successfully", alias), MessageType::Success, ); } Err(err) => { - self.display_message( - &format!("Failed to remove wallet: {}", err), + MessageBanner::set_global( + self.app_context.egui_ctx(), + format!("Failed to remove wallet: {}", err), MessageType::Error, ); } @@ -820,18 +835,6 @@ impl WalletsBalancesScreen { }); } - fn dismiss_message(&mut self) { - self.message = None; - } - - fn check_message_expiration(&mut self) { - // Messages no longer auto-expire, they must be dismissed manually - } - - fn set_message(&mut self, message: String, message_type: MessageType) { - self.message = Some((message, message_type, Utc::now())); - } - fn format_dash(amount_duffs: u64) -> String { Amount::dash_from_duffs(amount_duffs).to_string() } @@ -952,7 +955,11 @@ impl WalletsBalancesScreen { .create_screen(&self.app_context), ); } else { - self.display_message("Select a wallet first", MessageType::Error); + MessageBanner::set_global( + ui.ctx(), + "Select a wallet first", + MessageType::Error, + ); } } @@ -1608,8 +1615,9 @@ impl WalletsBalancesScreen { let mut wallet = match wallet_arc.write() { Ok(guard) => guard, Err(err) => { - self.display_message( - &format!("Failed to lock wallet: {}", err), + MessageBanner::set_global( + self.app_context.egui_ctx(), + format!("Failed to lock wallet: {}", err), MessageType::Error, ); return; @@ -1626,7 +1634,11 @@ impl WalletsBalancesScreen { if locked { self.app_context.handle_wallet_locked(&wallet_arc); - self.display_message("Wallet locked", MessageType::Info); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Wallet locked", + MessageType::Info, + ); } } @@ -1680,8 +1692,6 @@ impl WalletsBalancesScreen { impl ScreenLike for WalletsBalancesScreen { fn ui(&mut self, ctx: &Context) -> AppAction { - self.check_message_expiration(); - // Check for pending platform balance refresh (triggered after transfers) let pending_refresh_action = if let Some(seed_hash) = self.pending_platform_balance_refresh.take() @@ -1742,38 +1752,7 @@ impl ScreenLike for WalletsBalancesScreen { let mut inner_action = AppAction::None; let dark_mode = ui.ctx().style().visuals.dark_mode; - // Display messages at the top, outside of scroll area - let message = self.message.clone(); - if let Some((message, message_type, _timestamp)) = message { - let message_color = match message_type { - MessageType::Error => DashColors::ERROR, - MessageType::Warning => DashColors::WARNING, - MessageType::Info => DashColors::text_primary(dark_mode), - MessageType::Success => egui::Color32::DARK_GREEN, - }; - - // Display message in a prominent frame with text wrapping - Frame::new() - .fill(message_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, message_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.add( - egui::Label::new( - egui::RichText::new(&message).color(message_color), - ) - .wrap(), - ); - ui.add_space(5.0); - if ui.small_button("Dismiss").clicked() { - self.dismiss_message(); - } - }); - }); - ui.add_space(10.0); - } + // Message display is handled by the global MessageBanner egui::ScrollArea::vertical() .auto_shrink([true; 2]) @@ -1913,7 +1892,7 @@ impl ScreenLike for WalletsBalancesScreen { self.private_key_dialog.show_key = false; } Err(err) => { - self.display_message(&err, MessageType::Error); + MessageBanner::set_global(ctx, &err, MessageType::Error); } } } @@ -1939,10 +1918,14 @@ impl ScreenLike for WalletsBalancesScreen { if self.pending_asset_lock_search_after_unlock { self.pending_asset_lock_search_after_unlock = false; if let Some(wallet_arc) = self.selected_wallet.clone() { - self.display_message( + self.asset_lock_search_banner.take_and_clear(); + let handle = MessageBanner::set_global( + ctx, "Searching for unused asset locks...", MessageType::Info, ); + handle.with_elapsed(); + self.asset_lock_search_banner = Some(handle); action |= AppAction::BackendTask(BackendTask::CoreTask( CoreTask::RecoverAssetLocks(wallet_arc), )); @@ -2032,45 +2015,23 @@ impl ScreenLike for WalletsBalancesScreen { match unlock_result { Ok(_) => { - self.sk_error_message = None; close_dialog = true; } Err(_) => { - self.sk_error_message = - Some("Incorrect Password".to_string()); + MessageBanner::set_global(ui.ctx(), "Incorrect Password", MessageType::Error); } } } self.sk_wallet_password.clear(); } - // Display error message if the password was incorrect - if let Some(error_message) = self.sk_error_message.clone() { - ui.add_space(5.0); - let error_color = DashColors::ERROR; - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new(format!("Error: {}", error_message)).color(error_color)); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.sk_error_message = None; - } - }); - }); - } + // Error display is handled by the global MessageBanner. }); }); if close_dialog { self.show_sk_unlock_dialog = false; self.sk_wallet_password.clear(); - self.sk_error_message = None; - // Check if we were trying to refresh the SK wallet if self.pending_refresh_after_unlock { self.pending_refresh_after_unlock = false; @@ -2134,7 +2095,14 @@ impl ScreenLike for WalletsBalancesScreen { action = AppAction::None; } else { // Wallet is unlocked - proceed with search - self.display_message("Searching for unused asset locks...", MessageType::Info); + self.asset_lock_search_banner.take_and_clear(); + let handle = MessageBanner::set_global( + ctx, + "Searching for unused asset locks...", + MessageType::Info, + ); + handle.with_elapsed(); + self.asset_lock_search_banner = Some(handle); action = AppAction::BackendTask(BackendTask::CoreTask( CoreTask::RecoverAssetLocks(wallet_arc), )); @@ -2148,15 +2116,16 @@ impl ScreenLike for WalletsBalancesScreen { } fn display_message(&mut self, message: &str, message_type: MessageType) { - if let MessageType::Error = message_type { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { self.refreshing = false; + self.asset_lock_search_banner.take_and_clear(); // If the fund platform dialog is processing, show error in the dialog instead if self.fund_platform_dialog.is_processing { self.fund_platform_dialog.is_processing = false; self.fund_platform_dialog.status = Some(message.to_string()); self.fund_platform_dialog.status_is_error = true; - return; } // Forward errors to the shielded tab view so it can reset spinner states @@ -2164,7 +2133,6 @@ impl ScreenLike for WalletsBalancesScreen { shielded_view.handle_error(message); } } - self.set_message(message.to_string(), message_type); } fn display_task_result( @@ -2184,13 +2152,15 @@ impl ScreenLike for WalletsBalancesScreen { self.refresh_platform_sync_info_cache(&hash); } if let Some(warn_msg) = warning { - self.set_message( + MessageBanner::set_global( + self.app_context.egui_ctx(), format!("Wallet refreshed with warning: {}", warn_msg), MessageType::Info, ); } else { - self.set_message( - "Successfully refreshed wallet".to_string(), + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Successfully refreshed wallet", MessageType::Success, ); } @@ -2199,6 +2169,7 @@ impl ScreenLike for WalletsBalancesScreen { recovered_count, total_amount, } => { + self.asset_lock_search_banner.take_and_clear(); let msg = if recovered_count == 0 { "No additional unused asset locks found".to_string() } else { @@ -2208,7 +2179,7 @@ impl ScreenLike for WalletsBalancesScreen { Self::format_dash(total_amount) ) }; - self.display_message(&msg, MessageType::Success); + MessageBanner::set_global(self.app_context.egui_ctx(), &msg, MessageType::Success); } crate::ui::BackendTaskSuccessResult::WalletPayment { txid, @@ -2231,7 +2202,7 @@ impl ScreenLike for WalletsBalancesScreen { txid ) }; - self.display_message(&msg, MessageType::Success); + MessageBanner::set_global(self.app_context.egui_ctx(), &msg, MessageType::Success); } crate::ui::BackendTaskSuccessResult::GeneratedReceiveAddress { seed_hash, address } => { if let Some(selected) = &self.selected_wallet @@ -2257,16 +2228,25 @@ impl ScreenLike for WalletsBalancesScreen { } } crate::ui::BackendTaskSuccessResult::PlatformAddressWithdrawal { .. } => { - self.display_message("Platform withdrawal successful. Note: It may take a few minutes for funds to appear on the Core chain.", MessageType::Success); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Platform withdrawal successful. Note: It may take a few minutes for funds to appear on the Core chain.", + MessageType::Success, + ); } crate::ui::BackendTaskSuccessResult::PlatformAddressFunded { .. } => { self.fund_platform_dialog.is_processing = false; self.fund_platform_dialog.status = Some("Funding successful!".to_string()); self.fund_platform_dialog.status_is_error = false; - self.display_message("Platform address funded successfully", MessageType::Success); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Platform address funded successfully", + MessageType::Success, + ); } crate::ui::BackendTaskSuccessResult::PlatformCreditsTransferred { seed_hash } => { - self.display_message( + MessageBanner::set_global( + self.app_context.egui_ctx(), "Platform credits transferred successfully", MessageType::Success, ); @@ -2289,18 +2269,23 @@ impl ScreenLike for WalletsBalancesScreen { } } self.refresh_platform_sync_info_cache(&seed_hash); - self.set_message( - "Successfully synced Platform balances".to_string(), + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Successfully synced Platform balances", MessageType::Success, ); } crate::ui::BackendTaskSuccessResult::Message(msg) => { self.refreshing = false; - self.display_message(&msg, MessageType::Success); + MessageBanner::set_global(self.app_context.egui_ctx(), &msg, MessageType::Success); } crate::ui::BackendTaskSuccessResult::MineBlocksSuccess(count) => { self.refreshing = false; - self.display_message(&format!("Mined {} block(s)", count), MessageType::Success); + MessageBanner::set_global( + self.app_context.egui_ctx(), + format!("Mined {} block(s)", count), + MessageType::Success, + ); } // Shielded pool results result @ (crate::ui::BackendTaskSuccessResult::ShieldedInitialized { .. } diff --git a/src/utils/tasks.rs b/src/utils/tasks.rs index b44e4aee7..14193e83d 100644 --- a/src/utils/tasks.rs +++ b/src/utils/tasks.rs @@ -41,9 +41,36 @@ impl TaskManager { tokio::spawn(spawn_subtask(subtasks, name, future)); } - /// Shutdown all subtasks gracefully. + /// Start an asynchronous graceful shutdown of all subtasks. + /// + /// Cancels all tasks and returns a `oneshot::Receiver` that resolves when + /// shutdown is complete (or timed out). This does **not** block the calling + /// thread, so the UI can keep repainting while tasks wind down. + pub fn shutdown_async(&self) -> tokio::sync::oneshot::Receiver<()> { + let cancel = self.cancellation_token.clone(); + let subtasks = self.tasks.clone(); + let active_names = self.active_names.clone(); + + let (tx, rx) = tokio::sync::oneshot::channel::<()>(); + + tokio::task::spawn(async move { + let completed = shutdown_inner(&cancel, &subtasks, &active_names, "async").await; + + tracing::debug!( + "Async shutdown complete, {} subtasks finished cleanly", + completed + ); + + let _ = tx.send(()); + }); + + rx + } + + /// Shutdown all subtasks gracefully (blocking). /// /// Wait for all subtasks to finish within a specified timeout, and then abort them. + /// Blocks the calling thread. Prefer [`shutdown_async`] when the UI must stay responsive. /// /// This is an equivalent of `Runtime::shutdown_timeout` but for subtasks. pub fn shutdown(&self) -> Result<(), String> { @@ -52,110 +79,119 @@ impl TaskManager { let active_names = self.active_names.clone(); // a bit naive synchronization to wait for shutdown - let (tx, mut rx) = tokio::sync::oneshot::channel::<()>(); - // counter for logging - let completed = Arc::new(AtomicUsize::new(0)); + let (tx, mut rx) = tokio::sync::oneshot::channel::(); - let counter = completed.clone(); - let counter_for_timeout = completed.clone(); // we need to run this task in separate task to avoid cancelling it during shutdown tokio::task::spawn(async move { - // Cancel all background tasks - tracing::trace!("shutdown: cancelling all tasks"); - cancel.cancel(); - - // Wait for all subtasks to finish within SHUTDOWN_TIMEOUT - - let tasks_list = subtasks.clone(); - let names_for_join = active_names.clone(); - let timed_out = timeout(SHUTDOWN_TIMEOUT, async move { - let mut tasks = tasks_list.lock().await; - let total = tasks.len(); - tracing::trace!(total, "shutdown: joining tasks"); - let start = std::time::Instant::now(); - while let Some(handle) = tasks.join_next().await { - let i = counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; - match &handle { - Ok(name) => { - // Remove one instance of this name from active list - if let Ok(mut names) = names_for_join.lock() - && let Some(pos) = names.iter().position(|n| *n == *name) - { - names.swap_remove(pos); - } - tracing::trace!( - task = name, - task_num = i, - total, - elapsed_ms = start.elapsed().as_millis() as u64, - "shutdown: task joined OK" - ); - } - Err(e) => tracing::trace!( - task_num = i, - total, - elapsed_ms = start.elapsed().as_millis() as u64, - error = %e, - "shutdown: task joined with error" - ), - } - } - }) - .await; - - if timed_out.is_err() { - let done = counter_for_timeout.load(std::sync::atomic::Ordering::Relaxed); - let remaining: Vec<&str> = - active_names.lock().map(|n| n.clone()).unwrap_or_default(); - tracing::trace!( - completed = done, - remaining_count = remaining.len(), - remaining_tasks = ?remaining, - "shutdown: timed out waiting for tasks, aborting remaining" - ); - - #[cfg(tokio_unstable)] - { - let handle = tokio::runtime::Handle::current(); - let dump = handle.dump().await; - for (i, task) in dump.tasks().iter().enumerate() { - tracing::trace!( - task_num = i, - trace = %task.trace(), - "shutdown: active tokio task" - ); - } - } - } - - // now abort all tasks - subtasks.lock().await.shutdown().await; + let completed = shutdown_inner(&cancel, &subtasks, &active_names, "blocking").await; // notify that shutdown is complete - if tx.send(()).is_err() { + if tx.send(completed).is_err() { tracing::error!("Failed to send shutdown completion signal"); } }); // wait for the shutdown task to finish const WAIT_TIME: Duration = Duration::from_millis(100); + let mut completed = 0; for _ in 0..SHUTDOWN_TIMEOUT.as_millis() / WAIT_TIME.as_millis() { - if rx.try_recv().is_ok() { + if let Ok(count) = rx.try_recv() { + completed = count; break; } // wait for a short time to avoid busy waiting std::thread::sleep(WAIT_TIME); } - tracing::debug!( - "Shutdown complete, {} subtasks finished cleanly", - completed.load(std::sync::atomic::Ordering::Relaxed) - ); + tracing::debug!("Shutdown complete, {} subtasks finished cleanly", completed); Ok(()) } } +/// Shared shutdown logic: cancel all tasks, join with timeout, abort remaining. +/// +/// Returns the number of tasks that completed cleanly within the timeout. +/// The `label` is used in log messages to distinguish async vs blocking callers. +async fn shutdown_inner( + cancel: &CancellationToken, + subtasks: &Arc>>, + active_names: &Arc>>, + label: &str, +) -> usize { + tracing::trace!("{label}: cancelling all tasks"); + cancel.cancel(); + + let completed = Arc::new(AtomicUsize::new(0)); + + let counter = completed.clone(); + let tasks_list = subtasks.clone(); + let names_for_join = active_names.clone(); + let timed_out = timeout(SHUTDOWN_TIMEOUT, async move { + let mut tasks = tasks_list.lock().await; + let total = tasks.len(); + tracing::trace!(total, "{label}: joining tasks"); + let start = std::time::Instant::now(); + while let Some(handle) = tasks.join_next().await { + let i = counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + match &handle { + Ok(name) => { + // Remove one instance of this name from active list + if let Ok(mut names) = names_for_join.lock() + && let Some(pos) = names.iter().position(|n| *n == *name) + { + names.swap_remove(pos); + } + tracing::trace!( + task = name, + task_num = i, + total, + elapsed_ms = start.elapsed().as_millis() as u64, + "{label}: task joined OK" + ); + } + Err(e) => tracing::trace!( + task_num = i, + total, + elapsed_ms = start.elapsed().as_millis() as u64, + error = %e, + "{label}: task joined with error" + ), + } + } + }) + .await; + + if timed_out.is_err() { + let done = completed.load(std::sync::atomic::Ordering::Relaxed); + let remaining: Vec<&str> = active_names.lock().map(|n| n.clone()).unwrap_or_default(); + tracing::trace!( + completed = done, + remaining_count = remaining.len(), + remaining_tasks = ?remaining, + "{label}: timed out waiting for tasks, aborting remaining" + ); + + #[cfg(tokio_unstable)] + { + let handle = tokio::runtime::Handle::current(); + let dump = handle.dump().await; + for (i, task) in dump.tasks().iter().enumerate() { + tracing::trace!( + task_num = i, + trace = %task.trace(), + "{label}: active tokio task" + ); + } + } + } + + // Abort all remaining tasks + subtasks.lock().await.shutdown().await; + + completed.load(std::sync::atomic::Ordering::Relaxed) +} + #[inline(always)] async fn spawn_subtask( subtasks: Arc>>, From 508817a022f0bc3ddc8fe739792a2725db2c8a32 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 17 Mar 2026 20:09:26 +0700 Subject: [PATCH 012/147] chore: update platform dependency to 3.1-dev branch Migrate from feat/zk to 3.1-dev branch of dashpay/platform, adapting to breaking API changes in dash-spv, key-wallet, and dpp: - FeeLevel removed; use FeeRate::normal() directly - DashSpvClientInterface/Command removed; use DashSpvClient directly - SyncState::Initializing removed; replaced with WaitForEvents - NetworkExt trait inlined into Network impl - OrchardProver now requires wrapper struct around ProvingKey - OrchardAddress::from_raw_bytes now returns Result - Builder functions gain fee/platform_version params - NullifierSyncConfig API uses NullifierSyncCheckpoint - WalletManager.create_unsigned_payment_transaction removed; use TransactionBuilder directly - Work around Send lifetime issues with spawn_blocking Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 192 +++++++++--------- Cargo.toml | 2 +- src/app.rs | 60 +++--- src/backend_task/core/mod.rs | 76 +++++-- .../core/send_single_key_wallet_payment.rs | 11 +- src/backend_task/shielded/bundle.rs | 61 +++--- src/backend_task/shielded/nullifiers.rs | 17 +- src/backend_task/shielded/sync.rs | 2 +- src/spv/manager.rs | 100 ++++----- src/ui/network_chooser_screen.rs | 14 +- src/ui/tools/masternode_list_diff_screen.rs | 1 - src/ui/wallets/shielded_tab.rs | 6 +- src/ui/wallets/single_key_send_screen.rs | 8 +- 13 files changed, 292 insertions(+), 258 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3a62bf3dd..2fd14435c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1915,17 +1915,6 @@ dependencies = [ "zxcvbn", ] -[[package]] -name = "dash-network" -version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=a05d256f59743c69df912462dd77dd487e1ff5b2#a05d256f59743c69df912462dd77dd487e1ff5b2" -dependencies = [ - "bincode 2.0.1", - "bincode_derive", - "hex", - "serde", -] - [[package]] name = "dash-platform-macros" version = "3.1.0-dev.1" @@ -1954,11 +1943,12 @@ dependencies = [ "drive-proof-verifier", "envy", "futures", - "grovedb-commitment-tree", + "grovedb-commitment-tree 4.0.0 (git+https://github.com/dashpay/grovedb?rev=7ecb8465fad750c7cddd5332adb6f97fcceb498b)", "hex", "http", "js-sys", "lru", + "platform-encryption", "rs-dapi-client", "serde", "serde_json", @@ -1972,7 +1962,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=a05d256f59743c69df912462dd77dd487e1ff5b2#a05d256f59743c69df912462dd77dd487e1ff5b2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=9959201593826def0ad1f6db51b2ceb95b68a1ca#9959201593826def0ad1f6db51b2ceb95b68a1ca" dependencies = [ "anyhow", "async-trait", @@ -2005,7 +1995,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=a05d256f59743c69df912462dd77dd487e1ff5b2#a05d256f59743c69df912462dd77dd487e1ff5b2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=9959201593826def0ad1f6db51b2ceb95b68a1ca#9959201593826def0ad1f6db51b2ceb95b68a1ca" dependencies = [ "anyhow", "base64-compat", @@ -2015,7 +2005,6 @@ dependencies = [ "bitvec", "blake3", "blsful", - "dash-network", "dashcore-private", "dashcore_hashes", "ed25519-dalek", @@ -2031,12 +2020,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=a05d256f59743c69df912462dd77dd487e1ff5b2#a05d256f59743c69df912462dd77dd487e1ff5b2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=9959201593826def0ad1f6db51b2ceb95b68a1ca#9959201593826def0ad1f6db51b2ceb95b68a1ca" [[package]] name = "dashcore-rpc" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=a05d256f59743c69df912462dd77dd487e1ff5b2#a05d256f59743c69df912462dd77dd487e1ff5b2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=9959201593826def0ad1f6db51b2ceb95b68a1ca#9959201593826def0ad1f6db51b2ceb95b68a1ca" dependencies = [ "dashcore-rpc-json", "hex", @@ -2049,7 +2038,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=a05d256f59743c69df912462dd77dd487e1ff5b2#a05d256f59743c69df912462dd77dd487e1ff5b2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=9959201593826def0ad1f6db51b2ceb95b68a1ca#9959201593826def0ad1f6db51b2ceb95b68a1ca" dependencies = [ "bincode 2.0.1", "dashcore", @@ -2064,7 +2053,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=a05d256f59743c69df912462dd77dd487e1ff5b2#a05d256f59743c69df912462dd77dd487e1ff5b2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=9959201593826def0ad1f6db51b2ceb95b68a1ca#9959201593826def0ad1f6db51b2ceb95b68a1ca" dependencies = [ "bincode 2.0.1", "dashcore-private", @@ -2377,9 +2366,10 @@ dependencies = [ "dashcore-rpc", "data-contracts", "derive_more 1.0.0", + "dpp-json-convertible-derive", "env_logger", "getrandom 0.2.17", - "grovedb-commitment-tree", + "grovedb-commitment-tree 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", "hex", "indexmap 2.13.0", "integer-encoding", @@ -2401,11 +2391,20 @@ dependencies = [ "serde_json", "serde_repr", "sha2", - "strum 0.26.3", + "strum", "thiserror 2.0.18", "tracing", ] +[[package]] +name = "dpp-json-convertible-derive" +version = "3.1.0-dev.1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "drive" version = "3.1.0-dev.1" @@ -2414,11 +2413,11 @@ dependencies = [ "byteorder", "derive_more 1.0.0", "dpp", - "grovedb 4.0.0 (git+https://github.com/dashpay/grovedb?rev=56120fb5d0f8abfa9038e018115cdb11c51cde18)", - "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=56120fb5d0f8abfa9038e018115cdb11c51cde18)", + "grovedb 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", + "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", "grovedb-epoch-based-storage-flags", - "grovedb-path 4.0.0 (git+https://github.com/dashpay/grovedb?rev=56120fb5d0f8abfa9038e018115cdb11c51cde18)", - "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=56120fb5d0f8abfa9038e018115cdb11c51cde18)", + "grovedb-path 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", + "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", "hex", "indexmap 2.13.0", "integer-encoding", @@ -3593,25 +3592,25 @@ dependencies = [ [[package]] name = "grovedb" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=56120fb5d0f8abfa9038e018115cdb11c51cde18#56120fb5d0f8abfa9038e018115cdb11c51cde18" +source = "git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346#dd99ed1db0350e5f39127573808dd172c6bc2346" dependencies = [ "bincode 2.0.1", "bincode_derive", "blake3", "grovedb-bulk-append-tree", - "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=56120fb5d0f8abfa9038e018115cdb11c51cde18)", + "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", "grovedb-dense-fixed-sized-merkle-tree", - "grovedb-element 4.0.0 (git+https://github.com/dashpay/grovedb?rev=56120fb5d0f8abfa9038e018115cdb11c51cde18)", - "grovedb-merk 4.0.0 (git+https://github.com/dashpay/grovedb?rev=56120fb5d0f8abfa9038e018115cdb11c51cde18)", + "grovedb-element 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", + "grovedb-merk 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", "grovedb-merkle-mountain-range", - "grovedb-path 4.0.0 (git+https://github.com/dashpay/grovedb?rev=56120fb5d0f8abfa9038e018115cdb11c51cde18)", + "grovedb-path 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", "grovedb-query", - "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=56120fb5d0f8abfa9038e018115cdb11c51cde18)", + "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", "hex", "hex-literal", "indexmap 2.13.0", "integer-encoding", - "reqwest 0.12.28", + "reqwest 0.13.2", "sha2", "thiserror 2.0.18", ] @@ -3619,15 +3618,14 @@ dependencies = [ [[package]] name = "grovedb-bulk-append-tree" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=56120fb5d0f8abfa9038e018115cdb11c51cde18#56120fb5d0f8abfa9038e018115cdb11c51cde18" +source = "git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346#dd99ed1db0350e5f39127573808dd172c6bc2346" dependencies = [ "bincode 2.0.1", "blake3", - "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=56120fb5d0f8abfa9038e018115cdb11c51cde18)", + "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", "grovedb-dense-fixed-sized-merkle-tree", "grovedb-merkle-mountain-range", "grovedb-query", - "grovedb-storage", "hex", "thiserror 2.0.18", ] @@ -3635,8 +3633,10 @@ dependencies = [ [[package]] name = "grovedb-commitment-tree" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=56120fb5d0f8abfa9038e018115cdb11c51cde18#56120fb5d0f8abfa9038e018115cdb11c51cde18" +source = "git+https://github.com/dashpay/grovedb?rev=7ecb8465fad750c7cddd5332adb6f97fcceb498b#7ecb8465fad750c7cddd5332adb6f97fcceb498b" dependencies = [ + "blake3", + "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=7ecb8465fad750c7cddd5332adb6f97fcceb498b)", "incrementalmerkletree", "orchard", "rusqlite", @@ -3644,6 +3644,19 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "grovedb-commitment-tree" +version = "4.0.0" +source = "git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346#dd99ed1db0350e5f39127573808dd172c6bc2346" +dependencies = [ + "blake3", + "grovedb-bulk-append-tree", + "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", + "incrementalmerkletree", + "orchard", + "thiserror 2.0.18", +] + [[package]] name = "grovedb-costs" version = "4.0.0" @@ -3657,7 +3670,17 @@ dependencies = [ [[package]] name = "grovedb-costs" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=56120fb5d0f8abfa9038e018115cdb11c51cde18#56120fb5d0f8abfa9038e018115cdb11c51cde18" +source = "git+https://github.com/dashpay/grovedb?rev=7ecb8465fad750c7cddd5332adb6f97fcceb498b#7ecb8465fad750c7cddd5332adb6f97fcceb498b" +dependencies = [ + "integer-encoding", + "intmap", + "thiserror 2.0.18", +] + +[[package]] +name = "grovedb-costs" +version = "4.0.0" +source = "git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346#dd99ed1db0350e5f39127573808dd172c6bc2346" dependencies = [ "integer-encoding", "intmap", @@ -3667,13 +3690,12 @@ dependencies = [ [[package]] name = "grovedb-dense-fixed-sized-merkle-tree" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=56120fb5d0f8abfa9038e018115cdb11c51cde18#56120fb5d0f8abfa9038e018115cdb11c51cde18" +source = "git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346#dd99ed1db0350e5f39127573808dd172c6bc2346" dependencies = [ "bincode 2.0.1", "blake3", - "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=56120fb5d0f8abfa9038e018115cdb11c51cde18)", + "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", "grovedb-query", - "grovedb-storage", "thiserror 2.0.18", ] @@ -3694,12 +3716,12 @@ dependencies = [ [[package]] name = "grovedb-element" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=56120fb5d0f8abfa9038e018115cdb11c51cde18#56120fb5d0f8abfa9038e018115cdb11c51cde18" +source = "git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346#dd99ed1db0350e5f39127573808dd172c6bc2346" dependencies = [ "bincode 2.0.1", "bincode_derive", - "grovedb-path 4.0.0 (git+https://github.com/dashpay/grovedb?rev=56120fb5d0f8abfa9038e018115cdb11c51cde18)", - "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=56120fb5d0f8abfa9038e018115cdb11c51cde18)", + "grovedb-path 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", + "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", "hex", "integer-encoding", "thiserror 2.0.18", @@ -3708,9 +3730,9 @@ dependencies = [ [[package]] name = "grovedb-epoch-based-storage-flags" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=56120fb5d0f8abfa9038e018115cdb11c51cde18#56120fb5d0f8abfa9038e018115cdb11c51cde18" +source = "git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346#dd99ed1db0350e5f39127573808dd172c6bc2346" dependencies = [ - "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=56120fb5d0f8abfa9038e018115cdb11c51cde18)", + "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", "hex", "integer-encoding", "intmap", @@ -3741,19 +3763,19 @@ dependencies = [ [[package]] name = "grovedb-merk" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=56120fb5d0f8abfa9038e018115cdb11c51cde18#56120fb5d0f8abfa9038e018115cdb11c51cde18" +source = "git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346#dd99ed1db0350e5f39127573808dd172c6bc2346" dependencies = [ "bincode 2.0.1", "bincode_derive", "blake3", "byteorder", "ed", - "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=56120fb5d0f8abfa9038e018115cdb11c51cde18)", - "grovedb-element 4.0.0 (git+https://github.com/dashpay/grovedb?rev=56120fb5d0f8abfa9038e018115cdb11c51cde18)", - "grovedb-path 4.0.0 (git+https://github.com/dashpay/grovedb?rev=56120fb5d0f8abfa9038e018115cdb11c51cde18)", + "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", + "grovedb-element 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", + "grovedb-path 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", "grovedb-query", - "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=56120fb5d0f8abfa9038e018115cdb11c51cde18)", - "grovedb-visualize 4.0.0 (git+https://github.com/dashpay/grovedb?rev=56120fb5d0f8abfa9038e018115cdb11c51cde18)", + "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", + "grovedb-visualize 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", "hex", "indexmap 2.13.0", "integer-encoding", @@ -3763,12 +3785,11 @@ dependencies = [ [[package]] name = "grovedb-merkle-mountain-range" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=56120fb5d0f8abfa9038e018115cdb11c51cde18#56120fb5d0f8abfa9038e018115cdb11c51cde18" +source = "git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346#dd99ed1db0350e5f39127573808dd172c6bc2346" dependencies = [ "bincode 2.0.1", "blake3", - "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=56120fb5d0f8abfa9038e018115cdb11c51cde18)", - "grovedb-storage", + "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", ] [[package]] @@ -3782,7 +3803,7 @@ dependencies = [ [[package]] name = "grovedb-path" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=56120fb5d0f8abfa9038e018115cdb11c51cde18#56120fb5d0f8abfa9038e018115cdb11c51cde18" +source = "git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346#dd99ed1db0350e5f39127573808dd172c6bc2346" dependencies = [ "hex", ] @@ -3790,7 +3811,7 @@ dependencies = [ [[package]] name = "grovedb-query" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=56120fb5d0f8abfa9038e018115cdb11c51cde18#56120fb5d0f8abfa9038e018115cdb11c51cde18" +source = "git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346#dd99ed1db0350e5f39127573808dd172c6bc2346" dependencies = [ "bincode 2.0.1", "byteorder", @@ -3801,19 +3822,6 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "grovedb-storage" -version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=56120fb5d0f8abfa9038e018115cdb11c51cde18#56120fb5d0f8abfa9038e018115cdb11c51cde18" -dependencies = [ - "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=56120fb5d0f8abfa9038e018115cdb11c51cde18)", - "grovedb-path 4.0.0 (git+https://github.com/dashpay/grovedb?rev=56120fb5d0f8abfa9038e018115cdb11c51cde18)", - "grovedb-visualize 4.0.0 (git+https://github.com/dashpay/grovedb?rev=56120fb5d0f8abfa9038e018115cdb11c51cde18)", - "hex", - "strum 0.27.2", - "thiserror 2.0.18", -] - [[package]] name = "grovedb-version" version = "4.0.0" @@ -3826,7 +3834,7 @@ dependencies = [ [[package]] name = "grovedb-version" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=56120fb5d0f8abfa9038e018115cdb11c51cde18#56120fb5d0f8abfa9038e018115cdb11c51cde18" +source = "git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346#dd99ed1db0350e5f39127573808dd172c6bc2346" dependencies = [ "thiserror 2.0.18", "versioned-feature-core 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)", @@ -3844,7 +3852,7 @@ dependencies = [ [[package]] name = "grovedb-visualize" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=56120fb5d0f8abfa9038e018115cdb11c51cde18#56120fb5d0f8abfa9038e018115cdb11c51cde18" +source = "git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346#dd99ed1db0350e5f39127573808dd172c6bc2346" dependencies = [ "hex", "itertools 0.14.0", @@ -4715,7 +4723,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=a05d256f59743c69df912462dd77dd487e1ff5b2#a05d256f59743c69df912462dd77dd487e1ff5b2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=9959201593826def0ad1f6db51b2ceb95b68a1ca#9959201593826def0ad1f6db51b2ceb95b68a1ca" dependencies = [ "async-trait", "base58ck", @@ -4723,13 +4731,11 @@ dependencies = [ "bincode_derive", "bip39", "bitflags 2.11.0", - "dash-network", "dashcore", "dashcore-private", "dashcore_hashes", "getrandom 0.2.17", "hex", - "hkdf", "rand 0.8.5", "secp256k1", "serde", @@ -4742,7 +4748,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=a05d256f59743c69df912462dd77dd487e1ff5b2#a05d256f59743c69df912462dd77dd487e1ff5b2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=9959201593826def0ad1f6db51b2ceb95b68a1ca#9959201593826def0ad1f6db51b2ceb95b68a1ca" dependencies = [ "async-trait", "bincode 2.0.1", @@ -6079,6 +6085,16 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "platform-encryption" +version = "2.1.1" +dependencies = [ + "aes", + "cbc", + "dashcore", + "thiserror 1.0.69", +] + [[package]] name = "platform-serialization" version = "3.1.0-dev.1" @@ -6121,7 +6137,7 @@ name = "platform-version" version = "3.1.0-dev.1" dependencies = [ "bincode 2.0.1", - "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=56120fb5d0f8abfa9038e018115cdb11c51cde18)", + "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", "thiserror 2.0.18", "versioned-feature-core 1.0.0 (git+https://github.com/dashpay/versioned-feature-core)", ] @@ -6750,6 +6766,7 @@ dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", + "futures-channel", "futures-core", "futures-util", "h2", @@ -7695,16 +7712,7 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros 0.26.4", -] - -[[package]] -name = "strum" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" -dependencies = [ - "strum_macros 0.27.2", + "strum_macros", ] [[package]] @@ -7720,18 +7728,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "strum_macros" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "subtle" version = "2.6.1" diff --git a/Cargo.toml b/Cargo.toml index f244fa4da..56be00134 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ qrcode = "0.14.1" nix = { version = "0.31.1", features = ["signal"] } eframe = { version = "0.33.3", features = ["persistence"] } base64 = "0.22.1" -dash-sdk = { git = "https://github.com/dashpay/platform", branch = "feat/zk", features = [ +dash-sdk = { git = "https://github.com/dashpay/platform", branch = "3.1-dev", features = [ "core_key_wallet", "core_key_wallet_manager", "core_bincode", diff --git a/src/app.rs b/src/app.rs index b737aada4..6d12682e2 100644 --- a/src/app.rs +++ b/src/app.rs @@ -786,17 +786,21 @@ impl AppState { ); } - // Handle the backend task and send the result through the channel + // Handle the backend task and send the result through the channel. + // + // 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) { let sender = self.task_result_sender.clone(); let app_context = self.current_app_context().clone(); - tokio::spawn(async move { - let result = app_context.run_backend_task(task, sender.clone()).await; - - // Send the result back to the main thread - if let Err(e) = sender.send(result.into()).await { - tracing::error!("Failed to send task result: {}", e); - } + let handle = tokio::runtime::Handle::current(); + tokio::task::spawn_blocking(move || { + handle.block_on(async move { + let result = app_context.run_backend_task(task, sender.clone()).await; + if let Err(e) = sender.send(result.into()).await { + tracing::error!("Failed to send task result: {}", e); + } + }); }); } @@ -804,27 +808,29 @@ impl AppState { fn handle_backend_tasks(&self, tasks: Vec, mode: BackendTasksExecutionMode) { let sender = self.task_result_sender.clone(); let app_context = self.current_app_context().clone(); + let handle = tokio::runtime::Handle::current(); + + tokio::task::spawn_blocking(move || { + handle.block_on(async move { + let results = match mode { + BackendTasksExecutionMode::Sequential => { + app_context + .run_backend_tasks_sequential(tasks, sender.clone()) + .await + } + BackendTasksExecutionMode::Concurrent => { + app_context + .run_backend_tasks_concurrent(tasks, sender.clone()) + .await + } + }; - tokio::spawn(async move { - let results = match mode { - BackendTasksExecutionMode::Sequential => { - app_context - .run_backend_tasks_sequential(tasks, sender.clone()) - .await - } - BackendTasksExecutionMode::Concurrent => { - app_context - .run_backend_tasks_concurrent(tasks, sender.clone()) - .await - } - }; - - // Send the results back to the main thread - for result in results { - if let Err(e) = sender.send(result.into()).await { - tracing::error!("Failed to send task result: {}", e); + for result in results { + if let Err(e) = sender.send(result.into()).await { + tracing::error!("Failed to send task result: {}", e); + } } - } + }); }); } diff --git a/src/backend_task/core/mod.rs b/src/backend_task/core/mod.rs index 581bfba75..b6d85ab5a 100644 --- a/src/backend_task/core/mod.rs +++ b/src/backend_task/core/mod.rs @@ -22,11 +22,15 @@ use dash_sdk::dpp::dashcore::{ }; use dash_sdk::dpp::fee::Credits; use dash_sdk::dpp::key_wallet::Network as WalletNetwork; +use dash_sdk::dpp::key_wallet::account::ECDSAAddressDerivation; use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; -use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::fee::FeeLevel; -use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; +use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::coin_selection::SelectionStrategy; +use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::fee::FeeRate; +use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::transaction_builder::{ + BuilderError, TransactionBuilder, +}; use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; -use dash_sdk::dpp::key_wallet_manager::wallet_manager::{WalletError, WalletId, WalletManager}; +use dash_sdk::dpp::key_wallet_manager::wallet_manager::{WalletId, WalletManager}; use std::path::PathBuf; use std::str::FromStr; use std::sync::{Arc, RwLock}; @@ -507,7 +511,7 @@ impl AppContext { ) -> Result { const FALLBACK_STEP: u64 = 100; - let network = self.wallet_network_key(); + let _network = self.wallet_network_key(); let current_height = self .spv_manager() .status() @@ -525,28 +529,70 @@ impl AppContext { let mut scale_factor = 1.0f64; let mut attempted_fallback = false; + // Get UTXOs and change address from the wallet account + let (utxos, change_index) = { + let managed_info = wm + .get_wallet_info(wallet_id) + .ok_or_else(|| "Wallet info unavailable".to_string())?; + let account = managed_info + .accounts() + .standard_bip44_accounts + .get(&DEFAULT_BIP44_ACCOUNT_INDEX) + .ok_or_else(|| "BIP44 account missing".to_string())?; + + let utxos: Vec<_> = account.utxos.values().cloned().collect(); + let change_index = account.get_next_change_address_index().unwrap_or(0); + (utxos, change_index) + }; + + let wallet = wm + .get_wallet(wallet_id) + .ok_or_else(|| "Wallet object not found".to_string())?; + let wallet_account = wallet + .accounts + .standard_bip44_accounts + .get(&DEFAULT_BIP44_ACCOUNT_INDEX) + .ok_or_else(|| "BIP44 wallet account missing".to_string())?; + let change_addr = wallet_account + .derive_change_address(change_index) + .map_err(|e| format!("Failed to derive change address: {e}"))?; + loop { let scaled_recipients: Vec<(Address, u64)> = recipients .iter() .map(|(addr, amt)| (addr.clone(), (*amt as f64 * scale_factor) as u64)) .collect(); - match wm.create_unsigned_payment_transaction( - wallet_id, - DEFAULT_BIP44_ACCOUNT_INDEX, - Some(AccountTypePreference::BIP44), - scaled_recipients, - FeeLevel::Normal, - current_height, - ) { + let build_result = (|| -> Result { + let mut builder = TransactionBuilder::new() + .set_fee_rate(FeeRate::normal()) + .set_change_address(change_addr.clone()); + + for (addr, amt) in &scaled_recipients { + builder = builder.add_output(addr, *amt)?; + } + + builder = builder.select_inputs( + &utxos, + SelectionStrategy::LargestFirst, + current_height, + |_| None, // No private keys for unsigned tx + )?; + + builder.build() + })(); + + match build_result { Ok(tx) => return Ok(tx), - Err(WalletError::InsufficientFunds) if request.subtract_fee_from_amount => { + Err(BuilderError::InsufficientFunds { .. }) + if request.subtract_fee_from_amount => + { let next_scale = if !attempted_fallback { attempted_fallback = true; let fallback_amount = self.estimate_fallback_amount( wm, wallet_id, - network, + _network, DEFAULT_BIP44_ACCOUNT_INDEX, current_height, )?; @@ -600,7 +646,7 @@ impl AppContext { } let estimated_size = Self::estimate_p2pkh_tx_size(spendable_inputs, 1); - let fee = FeeLevel::Normal.fee_rate().calculate_fee(estimated_size); + let fee = FeeRate::normal().calculate_fee(estimated_size); Ok(spendable_total.saturating_sub(fee)) } diff --git a/src/backend_task/core/send_single_key_wallet_payment.rs b/src/backend_task/core/send_single_key_wallet_payment.rs index 490438819..7712be33c 100644 --- a/src/backend_task/core/send_single_key_wallet_payment.rs +++ b/src/backend_task/core/send_single_key_wallet_payment.rs @@ -9,7 +9,7 @@ use dash_sdk::dashcore_rpc::dashcore::{Address, OutPoint, ScriptBuf, Transaction use dash_sdk::dpp::dashcore::hashes::Hash; use dash_sdk::dpp::dashcore::sighash::SighashCache; use dash_sdk::dpp::dashcore::{EcdsaSighashType, secp256k1::Secp256k1}; -use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::fee::FeeLevel; +use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::fee::FeeRate; use std::str::FromStr; use std::sync::{Arc, RwLock}; @@ -64,8 +64,7 @@ impl AppContext { let num_outputs = outputs.len() + 1; // +1 for change let initial_fee_estimate = Self::estimate_p2pkh_tx_size(10, num_outputs); let initial_fee = request.override_fee.unwrap_or_else(|| { - FeeLevel::Normal - .fee_rate() + FeeRate::normal() .calculate_fee(initial_fee_estimate) }); @@ -91,7 +90,7 @@ impl AppContext { let current_size = Self::estimate_p2pkh_tx_size(selected.len(), num_outputs); let current_fee = request .override_fee - .unwrap_or_else(|| FeeLevel::Normal.fee_rate().calculate_fee(current_size)); + .unwrap_or_else(|| FeeRate::normal().calculate_fee(current_size)); if selected_total >= total_output + current_fee { break; @@ -102,7 +101,7 @@ impl AppContext { let final_size = Self::estimate_p2pkh_tx_size(selected.len(), num_outputs); let final_fee = request .override_fee - .unwrap_or_else(|| FeeLevel::Normal.fee_rate().calculate_fee(final_size)); + .unwrap_or_else(|| FeeRate::normal().calculate_fee(final_size)); if selected_total < total_output + final_fee { return Err(format!( @@ -124,7 +123,7 @@ impl AppContext { Self::estimate_p2pkh_tx_size(selected_utxos.len(), num_outputs_with_change); let fee = request .override_fee - .unwrap_or_else(|| FeeLevel::Normal.fee_rate().calculate_fee(estimated_size)); + .unwrap_or_else(|| FeeRate::normal().calculate_fee(estimated_size)); let total_input: u64 = selected_utxos.iter().map(|(_, tx_out)| tx_out.value).sum(); diff --git a/src/backend_task/shielded/bundle.rs b/src/backend_task/shielded/bundle.rs index f3d5b6a46..933e91dbe 100644 --- a/src/backend_task/shielded/bundle.rs +++ b/src/backend_task/shielded/bundle.rs @@ -8,15 +8,26 @@ use dash_sdk::dpp::address_funds::{ use dash_sdk::dpp::dashcore::Address; use dash_sdk::dpp::identity::core_script::CoreScript; use dash_sdk::dpp::shielded::builder::{ - SpendableNote, build_shield_transition, build_shielded_transfer_transition, + OrchardProver, SpendableNote, build_shield_transition, build_shielded_transfer_transition, build_shielded_withdrawal_transition, build_unshield_transition, }; use dash_sdk::dpp::withdrawal::Pooling; -use dash_sdk::grovedb_commitment_tree::{Nullifier, PaymentAddress}; +use dash_sdk::grovedb_commitment_tree::{Nullifier, PaymentAddress, ProvingKey}; use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; use std::collections::BTreeMap; use std::sync::{Arc, Mutex}; +/// Wrapper around a cached `ProvingKey` that implements `OrchardProver`. +struct CachedProver { + key: &'static ProvingKey, +} + +impl OrchardProver for CachedProver { + fn proving_key(&self) -> &ProvingKey { + self.key + } +} + /// Progress stage for a shield credits operation (used by batch UI). #[derive(Clone, Debug)] pub enum ShieldStage { @@ -78,8 +89,8 @@ pub fn build_shield_credit( ) -> Result { let sdk = { app_context.sdk.load().as_ref().clone() }; - let proving_key = get_proving_key(); - let recipient_addr = payment_address_to_orchard(recipient_payment_address); + let prover = CachedProver { key: get_proving_key() }; + let recipient_addr = payment_address_to_orchard(recipient_payment_address)?; let wallet_arc = { let wallets = app_context.wallets.read().unwrap(); @@ -100,7 +111,7 @@ pub fn build_shield_credit( fee_strategy, &*wallet, 0, - proving_key, + &prover, [0u8; 36], sdk.version(), ) @@ -122,10 +133,10 @@ pub async fn shield_credits( ) -> Result<(), String> { let sdk = { app_context.sdk.load().as_ref().clone() }; - let proving_key = get_proving_key(); + let prover = CachedProver { key: get_proving_key() }; // Build recipient Orchard address from our default payment address - let recipient_addr = payment_address_to_orchard(recipient_payment_address); + let recipient_addr = payment_address_to_orchard(recipient_payment_address)?; // Get the wallet for signing and nonce lookup let wallet_arc = { @@ -173,7 +184,7 @@ pub async fn shield_credits( fee_strategy, &*wallet, 0, - proving_key, + &prover, [0u8; 36], sdk.version(), ) @@ -204,13 +215,14 @@ pub async fn shielded_transfer( ) -> Result, String> { let sdk = { app_context.sdk.load().as_ref().clone() }; - let proving_key = get_proving_key(); + let prover = CachedProver { key: get_proving_key() }; // Parse recipient address let recipient_bytes: [u8; 43] = recipient_address_bytes .try_into() .map_err(|_| "Invalid recipient address length, expected 43 bytes")?; - let recipient_addr = OrchardAddress::from_raw_bytes(&recipient_bytes); + let recipient_addr = OrchardAddress::from_raw_bytes(&recipient_bytes) + .map_err(|e| format!("Invalid recipient address: {e}"))?; // Select notes to spend let (spendable_notes, _total_value) = select_notes_for_amount(shielded_state, amount)?; @@ -241,7 +253,7 @@ pub async fn shielded_transfer( (spends, anchor) }; - let change_addr = payment_address_to_orchard(&shielded_state.keys.default_address); + let change_addr = payment_address_to_orchard(&shielded_state.keys.default_address)?; let state_transition = build_shielded_transfer_transition( spends, @@ -251,8 +263,9 @@ pub async fn shielded_transfer( &shielded_state.keys.fvk, &shielded_state.keys.ask, anchor, - proving_key, + &prover, [0u8; 36], + None, sdk.version(), ) .map_err(|e| format!("Failed to build shielded transfer: {e}"))?; @@ -277,7 +290,7 @@ pub async fn unshield_credits( ) -> Result, String> { let sdk = { app_context.sdk.load().as_ref().clone() }; - let proving_key = get_proving_key(); + let prover = CachedProver { key: get_proving_key() }; // Select notes to spend let (spendable_notes, _total_value) = select_notes_for_amount(shielded_state, amount)?; @@ -308,7 +321,7 @@ pub async fn unshield_credits( (spends, anchor) }; - let change_addr = payment_address_to_orchard(&shielded_state.keys.default_address); + let change_addr = payment_address_to_orchard(&shielded_state.keys.default_address)?; let state_transition = build_unshield_transition( spends, @@ -318,8 +331,9 @@ pub async fn unshield_credits( &shielded_state.keys.fvk, &shielded_state.keys.ask, anchor, - proving_key, + &prover, [0u8; 36], + None, sdk.version(), ) .map_err(|e| format!("Failed to build unshield transition: {e}"))?; @@ -501,7 +515,8 @@ pub async fn shield_from_asset_lock( // Step 7: Build and broadcast the shield-from-asset-lock transition let sdk = { app_context.sdk.load().as_ref().clone() }; - let recipient = payment_address_to_orchard(&shielded_state.keys.default_address); + let recipient = payment_address_to_orchard(&shielded_state.keys.default_address)?; + let prover = CachedProver { key: proving_key }; // Shield only the user's requested amount; the extra duffs in the asset lock // cover the platform processing fee. @@ -517,8 +532,7 @@ pub async fn shield_from_asset_lock( shield_amount_credits, asset_lock_proof, asset_lock_private_key.inner.as_ref(), - 0, - proving_key, + &prover, [0u8; 36], sdk.version(), ) @@ -544,7 +558,7 @@ pub async fn shielded_withdrawal( ) -> Result, String> { let sdk = { app_context.sdk.load().as_ref().clone() }; - let proving_key = get_proving_key(); + let prover = CachedProver { key: get_proving_key() }; let output_script = CoreScript::from_bytes(to_core_address.script_pubkey().to_bytes()); @@ -573,7 +587,7 @@ pub async fn shielded_withdrawal( (spends, anchor) }; - let change_addr = payment_address_to_orchard(&shielded_state.keys.default_address); + let change_addr = payment_address_to_orchard(&shielded_state.keys.default_address)?; let state_transition = build_shielded_withdrawal_transition( spends, @@ -585,8 +599,9 @@ pub async fn shielded_withdrawal( &shielded_state.keys.fvk, &shielded_state.keys.ask, anchor, - proving_key, + &prover, [0u8; 36], + None, sdk.version(), ) .map_err(|e| format!("Failed to build shielded withdrawal transition: {e}"))?; @@ -636,7 +651,7 @@ fn select_notes_for_amount( } /// Convert a PaymentAddress to an OrchardAddress for the builder functions. -fn payment_address_to_orchard(addr: &PaymentAddress) -> OrchardAddress { +fn payment_address_to_orchard(addr: &PaymentAddress) -> Result { let raw = addr.to_raw_address_bytes(); - OrchardAddress::from_raw_bytes(&raw) + OrchardAddress::from_raw_bytes(&raw).map_err(|e| format!("Invalid orchard address: {e}")) } diff --git a/src/backend_task/shielded/nullifiers.rs b/src/backend_task/shielded/nullifiers.rs index 9c6aeef7e..7a0cc1cf0 100644 --- a/src/backend_task/shielded/nullifiers.rs +++ b/src/backend_task/shielded/nullifiers.rs @@ -2,7 +2,7 @@ use crate::context::AppContext; use crate::model::wallet::WalletSeedHash; use crate::model::wallet::shielded::ShieldedWalletState; use dash_sdk::dpp::dashcore::Network; -use dash_sdk::platform::nullifier_sync::NullifierSyncConfig; +use dash_sdk::platform::shielded::nullifier_sync::{NullifierSyncCheckpoint, NullifierSyncConfig}; use std::sync::Arc; /// Check which unspent notes have been spent on-chain using the SDK's @@ -35,13 +35,11 @@ pub async fn check_nullifiers( let last_height = shielded_state.last_nullifier_sync_height; let last_timestamp = shielded_state.last_nullifier_sync_timestamp; - let last_sync_height = if last_height > 0 { - Some(last_height) - } else { - None - }; - let last_sync_timestamp = if last_timestamp > 0 { - Some(last_timestamp) + let last_sync = if last_height > 0 || last_timestamp > 0 { + Some(NullifierSyncCheckpoint { + height: last_height, + timestamp: last_timestamp, + }) } else { None }; @@ -50,8 +48,7 @@ pub async fn check_nullifiers( .sync_nullifiers( &unspent_nullifiers, None::, - last_sync_height, - last_sync_timestamp, + last_sync, ) .await .map_err(|e| format!("Nullifier sync failed: {e}"))?; diff --git a/src/backend_task/shielded/sync.rs b/src/backend_task/shielded/sync.rs index 8c5531826..28667989b 100644 --- a/src/backend_task/shielded/sync.rs +++ b/src/backend_task/shielded/sync.rs @@ -3,7 +3,7 @@ use crate::model::wallet::WalletSeedHash; use crate::model::wallet::shielded::{ShieldedNote, ShieldedWalletState}; use dash_sdk::dpp::dashcore::Network; use dash_sdk::grovedb_commitment_tree::{Position, Retention}; -use dash_sdk::shielded::sync_shielded_notes; +use dash_sdk::platform::shielded::sync_shielded_notes; use std::sync::Arc; /// Server-enforced chunk size — start_index must be a multiple of this. diff --git a/src/spv/manager.rs b/src/spv/manager.rs index a2e9ddcd3..5bf374837 100644 --- a/src/spv/manager.rs +++ b/src/spv/manager.rs @@ -3,7 +3,6 @@ use crate::app_dir::app_user_data_dir_path; use crate::config::NetworkConfig; use crate::model::wallet::WalletSeedHash; use crate::utils::tasks::TaskManager; -use dash_sdk::dash_spv::client::interface::{DashSpvClientCommand, DashSpvClientInterface}; use dash_sdk::dash_spv::network::NetworkEvent; use dash_sdk::dash_spv::network::PeerNetworkManager; use dash_sdk::dash_spv::storage::DiskStorageManager; @@ -149,8 +148,8 @@ pub struct SpvManager { wallet: Arc>>, // Storage manager for direct access to SPV data (shared component from client) storage: Arc>>>>, - // Interface for sending commands to the running SPV client (quorum lookups, etc.) - client_interface: Arc>>, + // Clone of the running SPV client for direct queries (quorum lookups, etc.) + spv_client: Arc>>, status: Arc>, last_error: Arc>>, started_at: Arc>>, @@ -297,7 +296,7 @@ impl SpvManager { network, ))), storage: Arc::new(Mutex::new(None)), - client_interface: Arc::new(RwLock::new(None)), + spv_client: Arc::new(RwLock::new(None)), status: Arc::new(RwLock::new(SpvStatus::Idle)), last_error: Arc::new(RwLock::new(None)), started_at: Arc::new(RwLock::new(None)), @@ -551,8 +550,8 @@ impl SpvManager { *storage_guard = None; } - if let Ok(mut interface_guard) = self.client_interface.write() { - *interface_guard = None; + if let Ok(mut client_guard) = self.spv_client.write() { + *client_guard = None; } if let Ok(mut request_guard) = self.request_tx.lock() { @@ -606,8 +605,8 @@ impl SpvManager { /// Attempt to resolve a quorum public key via the SPV client's masternode/quorum state. /// - /// This method sends a request through the DashSpvClientInterface to query the running - /// SPV client. If SPV is not running or the key is not known, an error is returned. + /// This method queries the running SPV client directly for quorum data. + /// If SPV is not running or the key is not known, an error is returned. pub fn get_quorum_public_key( &self, quorum_type: u32, @@ -621,11 +620,11 @@ impl SpvManager { core_chain_locked_height ); - let interface = { + let client = { let guard = self - .client_interface + .spv_client .read() - .map_err(|e| format!("client_interface lock poisoned: {e}"))?; + .map_err(|e| format!("spv_client lock poisoned: {e}"))?; guard .clone() .ok_or_else(|| "SPV client not initialized".to_string())? @@ -643,8 +642,8 @@ impl SpvManager { tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async { - interface - .get_quorum_by_height(core_chain_locked_height, llmq_type, qh) + client + .get_quorum_at_height(core_chain_locked_height, llmq_type, qh) .await .map(|q| { tracing::debug!( @@ -825,13 +824,9 @@ impl SpvManager { } } - // Build and start the client + // Build the client (run() will call start() internally) let has_wallets = expected_wallet_count > 0; - let mut client = self.build_client(has_wallets).await?; - client - .start() - .await - .map_err(|e| format!("SPV start failed: {e}"))?; + let client = self.build_client(has_wallets).await?; // Store the shared storage reference for later access { @@ -842,7 +837,7 @@ impl SpvManager { } // Subscribe to sync events (broadcast) - let sync_rx = client.subscribe_sync_events(); + let sync_rx = client.subscribe_sync_events().await; self.spawn_sync_event_handler(sync_rx); // Subscribe to wallet events (broadcast from WalletManager) @@ -853,11 +848,11 @@ impl SpvManager { } // Subscribe to network events (broadcast) - let net_rx = client.subscribe_network_events(); + let net_rx = client.subscribe_network_events().await; self.spawn_network_event_handler(net_rx); // Set up progress handler using watch channel - let progress_rx = client.subscribe_progress(); + let progress_rx = client.subscribe_progress().await; self.spawn_progress_watcher(progress_rx); // Set up request handler with access to shared components @@ -871,19 +866,13 @@ impl SpvManager { // Spawn request handler in a separate task self.spawn_request_handler(request_rx, stop_token.clone()); - // Create command channel for the DashSpvClientInterface - // Note: Unbounded channel is required by SDK's DashSpvClientInterface API. - // Memory usage is bounded in practice by SPV command processing speed. - let (command_tx, command_receiver) = tokio::sync::mpsc::unbounded_channel(); - - // Store the interface for external queries (quorum lookups, etc.) + // Store a clone of the client for external queries (quorum lookups, etc.) { - let interface = DashSpvClientInterface::new(command_tx); let mut guard = self - .client_interface + .spv_client .write() - .map_err(|e| format!("client_interface lock poisoned: {e}"))?; - *guard = Some(interface); + .map_err(|e| format!("spv_client lock poisoned: {e}"))?; + *guard = Some(client.clone()); } let _ = self.write_status(SpvStatus::Syncing); @@ -891,12 +880,12 @@ impl SpvManager { // Run sync and monitor with the client owned in this scope let result = self .clone() - .run_sync_and_monitor(client, command_receiver, stop_token) + .run_sync_and_monitor(client, stop_token) .await; - // Clear the interface and network manager since the client is done + // Clear the client reference and network manager since the client is done { - if let Ok(mut guard) = self.client_interface.write() { + if let Ok(mut guard) = self.spv_client.write() { *guard = None; } } @@ -922,58 +911,49 @@ impl SpvManager { async fn run_sync_and_monitor( self: Arc, - mut client: SpvClient, - command_receiver: mpsc::UnboundedReceiver, + client: SpvClient, stop_token: CancellationToken, ) -> Result<(), String> { - // Monitor network continuously - this handles initial sync and ongoing monitoring - // Requests are handled through the DashSpvClientInterface command channel + // Run the client continuously - this handles initial sync and ongoing monitoring. + // The client's run() method calls start() internally and monitors until the + // cancellation token is triggered, then calls stop() before returning. enum Outcome { - MonitorCompleted(Result<(), dash_sdk::dash_spv::SpvError>), + RunCompleted(Result<(), dash_sdk::dash_spv::SpvError>), Cancelled, } let outcome = { - let monitor_cancel = CancellationToken::new(); - let monitor_future = client.monitor_network(command_receiver, monitor_cancel.clone()); - tokio::pin!(monitor_future); + let run_cancel = CancellationToken::new(); + let run_future = client.run(run_cancel.clone()); + tokio::pin!(run_future); // stop_token is a child of global_cancel, so it fires on either // explicit SpvManager::stop() or application-wide shutdown. tokio::select! { - result = &mut monitor_future => Outcome::MonitorCompleted(result), + result = &mut run_future => Outcome::RunCompleted(result), _ = stop_token.cancelled() => { - monitor_cancel.cancel(); + run_cancel.cancel(); Outcome::Cancelled }, } - }; // monitor_future is dropped here, releasing the mutable borrow + }; // run_future is dropped here, releasing the borrow tracing::info!( "run_sync_and_monitor: outcome = {}", match &outcome { - Outcome::MonitorCompleted(Ok(())) => "MonitorCompleted(Ok)", - Outcome::MonitorCompleted(Err(_)) => "MonitorCompleted(Err)", + Outcome::RunCompleted(Ok(())) => "RunCompleted(Ok)", + Outcome::RunCompleted(Err(_)) => "RunCompleted(Err)", Outcome::Cancelled => "Cancelled", } ); - // Stop the client after monitoring completes or is cancelled - tracing::info!("run_sync_and_monitor: calling client.stop()..."); - let stop_start = std::time::Instant::now(); - let _ = client.stop().await; - tracing::info!( - "run_sync_and_monitor: client.stop() took {:?}", - stop_start.elapsed() - ); - match outcome { - Outcome::MonitorCompleted(Ok(())) => { + Outcome::RunCompleted(Ok(())) => { let _ = self.write_status(SpvStatus::Stopped); Ok(()) } - Outcome::MonitorCompleted(Err(err)) => { - let message = format!("monitor_network failed: {err}"); + Outcome::RunCompleted(Err(err)) => { + let message = format!("client.run() failed: {err}"); let _ = self.write_last_error(Some(message.clone())); let _ = self.write_status(SpvStatus::Error); Err(message) diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index 598689f9a..f1d4e8a80 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -1782,9 +1782,8 @@ impl NetworkChooserScreen { } } SyncState::Synced => 1.0, - SyncState::Initializing + SyncState::WaitForEvents | SyncState::WaitingForConnections - | SyncState::WaitForEvents | SyncState::Error => 0.0, } } @@ -1819,9 +1818,8 @@ impl NetworkChooserScreen { } } SyncState::Synced => 1.0, - SyncState::Initializing + SyncState::WaitForEvents | SyncState::WaitingForConnections - | SyncState::WaitForEvents | SyncState::Error => 0.0, } } @@ -1859,9 +1857,8 @@ impl NetworkChooserScreen { } } SyncState::Synced => 1.0, - SyncState::Initializing + SyncState::WaitForEvents | SyncState::WaitingForConnections - | SyncState::WaitForEvents | SyncState::Error => 0.0, } } @@ -1885,9 +1882,8 @@ impl NetworkChooserScreen { (mn.current_height() as f32 / target as f32).clamp(0.0, 1.0) } SyncState::Synced => 1.0, - SyncState::Initializing + SyncState::WaitForEvents | SyncState::WaitingForConnections - | SyncState::WaitForEvents | SyncState::Error => 0.0, } } @@ -2007,7 +2003,7 @@ impl NetworkChooserScreen { SyncState::WaitingForConnections => "Connecting to peers".to_string(), SyncState::WaitForEvents => "Querying peer heights".to_string(), SyncState::Error => "Sync error".to_string(), - SyncState::Initializing | SyncState::Syncing | SyncState::Synced => { + SyncState::Syncing | SyncState::Synced => { "Syncing...".to_string() } } diff --git a/src/ui/tools/masternode_list_diff_screen.rs b/src/ui/tools/masternode_list_diff_screen.rs index 41978be75..296c1b1cc 100644 --- a/src/ui/tools/masternode_list_diff_screen.rs +++ b/src/ui/tools/masternode_list_diff_screen.rs @@ -16,7 +16,6 @@ use dash_sdk::dpp::dashcore::bls_sig_utils::BLSSignature; use dash_sdk::dpp::dashcore::consensus::serialize as serialize2; use dash_sdk::dpp::dashcore::consensus::{Decodable, deserialize, serialize}; use dash_sdk::dpp::dashcore::hashes::Hash; -use dash_sdk::dpp::dashcore::network::constants::NetworkExt; use dash_sdk::dpp::dashcore::network::message_qrinfo::{QRInfo, QuorumSnapshot}; use dash_sdk::dpp::dashcore::network::message_sml::MnListDiff; use dash_sdk::dpp::dashcore::sml::llmq_entry_verification::LLMQEntryVerificationStatus; diff --git a/src/ui/wallets/shielded_tab.rs b/src/ui/wallets/shielded_tab.rs index 5775a03a7..c7631c1b0 100644 --- a/src/ui/wallets/shielded_tab.rs +++ b/src/ui/wallets/shielded_tab.rs @@ -341,7 +341,7 @@ impl ShieldedTabView { // Payment address (bech32m encoded: dash1z... or tdash1z...) let address_str = { let states = self.app_context.shielded_states.lock().unwrap(); - states.get(&self.seed_hash).map(|state| { + states.get(&self.seed_hash).and_then(|state| { use dash_sdk::dpp::address_funds::OrchardAddress; use dash_sdk::grovedb_commitment_tree::Scope; let addr = state @@ -349,8 +349,8 @@ impl ShieldedTabView { .fvk .address_at(self.selected_address_index, Scope::External); let raw = addr.to_raw_address_bytes(); - let orchard_addr = OrchardAddress::from_raw_bytes(&raw); - orchard_addr.to_bech32m_string(self.app_context.network) + let orchard_addr = OrchardAddress::from_raw_bytes(&raw).ok()?; + Some(orchard_addr.to_bech32m_string(self.app_context.network)) }) }; diff --git a/src/ui/wallets/single_key_send_screen.rs b/src/ui/wallets/single_key_send_screen.rs index 9528d9020..47b52d8d5 100644 --- a/src/ui/wallets/single_key_send_screen.rs +++ b/src/ui/wallets/single_key_send_screen.rs @@ -12,7 +12,7 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::theme::DashColors; use crate::ui::{MessageType, RootScreenType, ScreenLike}; use chrono::{DateTime, Utc}; -use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::fee::FeeLevel; +use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::fee::FeeRate; use eframe::egui::{self, Context, RichText, Ui}; use egui::{Color32, Frame, Margin}; use std::sync::{Arc, RwLock}; @@ -152,7 +152,7 @@ impl SingleKeyWalletSendScreen { // No valid amounts entered yet, show estimate for minimum tx let output_count = self.recipients.len().max(1) + 1; let estimated_size = Self::estimate_p2pkh_tx_size(1, output_count); - let fee = FeeLevel::Normal.fee_rate().calculate_fee(estimated_size); + let fee = FeeRate::normal().calculate_fee(estimated_size); return Some((fee, 1, estimated_size)); } @@ -172,7 +172,7 @@ impl SingleKeyWalletSendScreen { // Recalculate fee with current input count let current_size = Self::estimate_p2pkh_tx_size(selected_count, output_count); - let current_fee = FeeLevel::Normal.fee_rate().calculate_fee(current_size); + let current_fee = FeeRate::normal().calculate_fee(current_size); if selected_total >= total_output + current_fee { return Some((current_fee, selected_count, current_size)); @@ -181,7 +181,7 @@ impl SingleKeyWalletSendScreen { // Not enough funds - show what we'd need with all UTXOs let estimated_size = Self::estimate_p2pkh_tx_size(selected_count, output_count); - let fee = FeeLevel::Normal.fee_rate().calculate_fee(estimated_size); + let fee = FeeRate::normal().calculate_fee(estimated_size); Some((fee, selected_count, estimated_size)) } From 0cb6d7e7b5ef7ceaa5011cf69372197c19a7029e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 17 Mar 2026 14:16:52 +0100 Subject: [PATCH 013/147] refactor: migrate shielded module from Result to typed TaskError Replace all Result error patterns in the shielded pool module with typed TaskError variants, aligning with the codebase-wide typed error migration (PR #739). New TaskError variants: ShieldedNoUnspentNotes, ShieldedInsufficientBalance, PlatformAddressNotFound, ShieldedMerkleWitnessUnavailable, ShieldedTransitionBuildFailed, ShieldedBroadcastFailed, ShieldedInvalidRecipientAddress, ShieldedAssetLockTimeout, ShieldedSyncFailed, ShieldedTreeUpdateFailed, ShieldedNullifierSyncFailed. Co-Authored-By: Claude Opus 4.6 --- src/backend_task/error.rs | 60 +++++++ src/backend_task/shielded/bundle.rs | 213 ++++++++++++++---------- src/backend_task/shielded/nullifiers.rs | 7 +- src/backend_task/shielded/sync.rs | 28 +++- src/context/shielded.rs | 61 +++---- src/ui/wallets/shield_credits_screen.rs | 5 +- 6 files changed, 231 insertions(+), 143 deletions(-) diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index 3589cffdb..9de10b93f 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -592,6 +592,66 @@ pub enum TaskError { "This identity does not have a key for signing documents. Please add an authentication key." )] NoDocumentSigningKey, + + // ────────────────────────────────────────────────────────────────────────── + // Shielded pool errors + // ────────────────────────────────────────────────────────────────────────── + /// No unspent shielded notes are available. + #[error("You have no shielded funds available. Please shield some credits first.")] + ShieldedNoUnspentNotes, + + /// Insufficient shielded balance to cover the requested amount. + #[error( + "Insufficient shielded balance: you have {available} credits but need {required}. Please shield more credits." + )] + ShieldedInsufficientBalance { available: u64, required: u64 }, + + /// The platform address was not found in the wallet's platform address info. + #[error("The platform address could not be found in your wallet. Please refresh and retry.")] + PlatformAddressNotFound, + + /// A Merkle witness could not be obtained for a shielded note. + #[error("Could not prepare the shielded transaction. Please sync your notes and retry.")] + ShieldedMerkleWitnessUnavailable { detail: String }, + + /// Failed to build a shielded state transition (shield, transfer, unshield, withdrawal). + #[error("Could not build the shielded transaction. Please retry.")] + ShieldedTransitionBuildFailed { detail: String }, + + /// Failed to broadcast a shielded state transition. + #[error( + "Could not broadcast the shielded transaction. Please check your connection and retry." + )] + ShieldedBroadcastFailed { + #[source] + source: Box, + }, + + /// Invalid recipient address for shielded transfer. + #[error("The recipient shielded address is invalid. Please check the address and retry.")] + ShieldedInvalidRecipientAddress, + + /// Timed out waiting for asset lock proof during shield-from-asset-lock. + #[error( + "The funding transaction was not confirmed within 5 minutes. Please check your network connection and retry." + )] + ShieldedAssetLockTimeout, + + /// Failed to sync shielded notes from the platform. + #[error( + "Could not sync shielded notes from the platform. Please check your connection and retry." + )] + ShieldedSyncFailed { detail: String }, + + /// Failed to append or checkpoint the shielded commitment tree. + #[error( + "Could not update the local shielded data. Please check available disk space and retry." + )] + ShieldedTreeUpdateFailed { detail: String }, + + /// Nullifier sync failed. + #[error("Could not check for spent shielded notes. Please check your connection and retry.")] + ShieldedNullifierSyncFailed { detail: String }, } /// Returns `true` when the SDK error indicates an invalid instant asset lock diff --git a/src/backend_task/shielded/bundle.rs b/src/backend_task/shielded/bundle.rs index ab322af43..8050122ca 100644 --- a/src/backend_task/shielded/bundle.rs +++ b/src/backend_task/shielded/bundle.rs @@ -1,3 +1,4 @@ +use crate::backend_task::error::TaskError; use crate::context::AppContext; use crate::context::shielded::get_proving_key; use crate::model::wallet::WalletSeedHash; @@ -75,7 +76,7 @@ pub fn build_shield_credit( amount: u64, from_address: PlatformAddress, nonce: u32, -) -> Result { +) -> Result { let sdk = { app_context.sdk.load().as_ref().clone() }; let proving_key = get_proving_key(); @@ -83,7 +84,10 @@ pub fn build_shield_credit( let wallet_arc = { let wallets = app_context.wallets.read().unwrap(); - wallets.get(seed_hash).cloned().ok_or("Wallet not found")? + wallets + .get(seed_hash) + .cloned() + .ok_or(TaskError::WalletNotFound)? }; let mut inputs = BTreeMap::new(); @@ -104,7 +108,9 @@ pub fn build_shield_credit( [0u8; 36], sdk.version(), ) - .map_err(|e| format!("Failed to build shield transition: {e}")) + .map_err(|e| TaskError::ShieldedTransitionBuildFailed { + detail: e.to_string(), + }) } /// Build and broadcast a Shield transition (transparent -> shielded pool). @@ -119,22 +125,21 @@ pub async fn shield_credits( from_address: PlatformAddress, nonce_override: Option, stage: Option>>, -) -> Result<(), String> { +) -> Result<(), TaskError> { let sdk = { app_context.sdk.load().as_ref().clone() }; let proving_key = get_proving_key(); - // Build recipient Orchard address from our default payment address let recipient_addr = payment_address_to_orchard(recipient_payment_address); - // Get the wallet for signing and nonce lookup let wallet_arc = { let wallets = app_context.wallets.read().unwrap(); - wallets.get(seed_hash).cloned().ok_or("Wallet not found")? + wallets + .get(seed_hash) + .cloned() + .ok_or(TaskError::WalletNotFound)? }; - // Get the nonce for the input address from the wallet's platform address info, - // unless a nonce override was provided (batch parallel mode). let nonce: u32 = if let Some(n) = nonce_override { n } else { @@ -150,7 +155,7 @@ pub async fn shield_credits( None } }) - .ok_or("Platform address not found in wallet")? + .ok_or(TaskError::PlatformAddressNotFound)? }; let mut inputs = BTreeMap::new(); @@ -163,7 +168,6 @@ pub async fn shield_credits( *s.lock().unwrap() = ShieldStage::BuildingProof { nonce }; } - // Use the DPP builder which handles bundle construction internally let state_transition = { let wallet = wallet_arc.read().unwrap(); build_shield_transition( @@ -177,17 +181,20 @@ pub async fn shield_credits( [0u8; 36], sdk.version(), ) - .map_err(|e| format!("Failed to build shield transition: {e}"))? + .map_err(|e| TaskError::ShieldedTransitionBuildFailed { + detail: e.to_string(), + })? }; if let Some(s) = &stage { *s.lock().unwrap() = ShieldStage::Broadcasting; } - state_transition - .broadcast(&sdk, None) - .await - .map_err(|e| format!("Failed to broadcast shield transition: {e}"))?; + state_transition.broadcast(&sdk, None).await.map_err(|e| { + TaskError::ShieldedBroadcastFailed { + source: Box::new(e), + } + })?; Ok(()) } @@ -201,24 +208,20 @@ pub async fn shielded_transfer( shielded_state: &ShieldedWalletState, amount: u64, recipient_address_bytes: &[u8], -) -> Result, String> { +) -> Result, TaskError> { let sdk = { app_context.sdk.load().as_ref().clone() }; let proving_key = get_proving_key(); - // Parse recipient address let recipient_bytes: [u8; 43] = recipient_address_bytes .try_into() - .map_err(|_| "Invalid recipient address length, expected 43 bytes")?; + .map_err(|_| TaskError::ShieldedInvalidRecipientAddress)?; let recipient_addr = OrchardAddress::from_raw_bytes(&recipient_bytes); - // Select notes to spend let (spendable_notes, _total_value) = select_notes_for_amount(shielded_state, amount)?; - // Collect nullifiers of the notes we're about to spend let spent_nullifiers: Vec = spendable_notes.iter().map(|n| n.nullifier).collect(); - // Get Merkle witness and anchor (lock scoped to avoid holding across await) let (spends, anchor) = { let tree = shielded_state.commitment_tree.lock().unwrap(); let spends = spendable_notes @@ -226,18 +229,24 @@ pub async fn shielded_transfer( .map(|note| { let merkle_path = tree .witness(note.position, 0) - .map_err(|e| format!("Failed to get Merkle witness: {e}"))? - .ok_or("No Merkle path available for note")?; + .map_err(|e| TaskError::ShieldedMerkleWitnessUnavailable { + detail: e.to_string(), + })? + .ok_or(TaskError::ShieldedMerkleWitnessUnavailable { + detail: "No Merkle path available for note".into(), + })?; Ok(SpendableNote { note: note.note, merkle_path, }) }) - .collect::, String>>()?; + .collect::, TaskError>>()?; let anchor = tree .anchor() - .map_err(|e| format!("Failed to get tree anchor: {e}"))?; + .map_err(|e| TaskError::ShieldedMerkleWitnessUnavailable { + detail: e.to_string(), + })?; (spends, anchor) }; @@ -255,12 +264,15 @@ pub async fn shielded_transfer( [0u8; 36], sdk.version(), ) - .map_err(|e| format!("Failed to build shielded transfer: {e}"))?; + .map_err(|e| TaskError::ShieldedTransitionBuildFailed { + detail: e.to_string(), + })?; - state_transition - .broadcast(&sdk, None) - .await - .map_err(|e| format!("Failed to broadcast shielded transfer: {e}"))?; + state_transition.broadcast(&sdk, None).await.map_err(|e| { + TaskError::ShieldedBroadcastFailed { + source: Box::new(e), + } + })?; Ok(spent_nullifiers) } @@ -274,18 +286,15 @@ pub async fn unshield_credits( shielded_state: &ShieldedWalletState, amount: u64, to_platform_address: PlatformAddress, -) -> Result, String> { +) -> Result, TaskError> { let sdk = { app_context.sdk.load().as_ref().clone() }; let proving_key = get_proving_key(); - // Select notes to spend let (spendable_notes, _total_value) = select_notes_for_amount(shielded_state, amount)?; - // Collect nullifiers of the notes we're about to spend let spent_nullifiers: Vec = spendable_notes.iter().map(|n| n.nullifier).collect(); - // Get Merkle witness and anchor (lock scoped to avoid holding across await) let (spends, anchor) = { let tree = shielded_state.commitment_tree.lock().unwrap(); let spends = spendable_notes @@ -293,18 +302,24 @@ pub async fn unshield_credits( .map(|note| { let merkle_path = tree .witness(note.position, 0) - .map_err(|e| format!("Failed to get Merkle witness: {e}"))? - .ok_or("No Merkle path available for note")?; + .map_err(|e| TaskError::ShieldedMerkleWitnessUnavailable { + detail: e.to_string(), + })? + .ok_or(TaskError::ShieldedMerkleWitnessUnavailable { + detail: "No Merkle path available for note".into(), + })?; Ok(SpendableNote { note: note.note, merkle_path, }) }) - .collect::, String>>()?; + .collect::, TaskError>>()?; let anchor = tree .anchor() - .map_err(|e| format!("Failed to get tree anchor: {e}"))?; + .map_err(|e| TaskError::ShieldedMerkleWitnessUnavailable { + detail: e.to_string(), + })?; (spends, anchor) }; @@ -322,12 +337,15 @@ pub async fn unshield_credits( [0u8; 36], sdk.version(), ) - .map_err(|e| format!("Failed to build unshield transition: {e}"))?; + .map_err(|e| TaskError::ShieldedTransitionBuildFailed { + detail: e.to_string(), + })?; - state_transition - .broadcast(&sdk, None) - .await - .map_err(|e| format!("Failed to broadcast unshield transition: {e}"))?; + state_transition.broadcast(&sdk, None).await.map_err(|e| { + TaskError::ShieldedBroadcastFailed { + source: Box::new(e), + } + })?; Ok(spent_nullifiers) } @@ -343,7 +361,7 @@ pub async fn shield_from_asset_lock( seed_hash: &WalletSeedHash, shielded_state: &ShieldedWalletState, amount_duffs: u64, -) -> Result { +) -> Result { use dash_sdk::dashcore_rpc::RpcApi; use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; use dash_sdk::dpp::prelude::AssetLockProof; @@ -353,13 +371,10 @@ pub async fn shield_from_asset_lock( let proving_key = crate::context::shielded::get_proving_key(); - // Platform charges a processing fee on top of the shielded amount, so we must - // inflate the asset lock to cover both the shield amount and the fee. let platform_fee_credits = app_context .fee_estimator() .min_fees() .address_funding_asset_lock_cost; - // Add a 20% safety buffer to the platform fee estimate let platform_fee_duffs = (platform_fee_credits / CREDITS_PER_DUFF).saturating_mul(120) / 100; let asset_lock_duffs = amount_duffs.saturating_add(platform_fee_duffs); @@ -370,12 +385,13 @@ pub async fn shield_from_asset_lock( wallets .get(seed_hash) .cloned() - .ok_or_else(|| "Wallet not found".to_string())? + .ok_or(TaskError::WalletNotFound)? }; - let mut wallet = wallet_arc.write().map_err(|e| e.to_string())?; + let mut wallet = wallet_arc + .write() + .map_err(|_| TaskError::LockPoisoned { resource: "wallet" })?; - // Try to create the asset lock transaction, reload UTXOs if needed match wallet.generic_asset_lock_transaction( app_context.as_ref(), app_context.network, @@ -384,10 +400,9 @@ pub async fn shield_from_asset_lock( ) { Ok((tx, private_key, address, _change, utxos)) => (tx, private_key, address, utxos), Err(_) => { - // Reload UTXOs and try again wallet .reload_utxos(app_context.as_ref()) - .map_err(|e| e.to_string())?; + .map_err(|e| TaskError::ShieldedTransitionBuildFailed { detail: e })?; let (tx, private_key, address, _change, utxos) = wallet .generic_asset_lock_transaction( @@ -395,7 +410,8 @@ pub async fn shield_from_asset_lock( app_context.network, asset_lock_duffs, false, - )?; + ) + .map_err(|e| TaskError::ShieldedTransitionBuildFailed { detail: e })?; (tx, private_key, address, utxos) } } @@ -417,8 +433,7 @@ pub async fn shield_from_asset_lock( .core_client .read() .expect("Core client lock was poisoned") - .send_raw_transaction(&asset_lock_transaction) - .map_err(|e| format!("Failed to broadcast asset lock transaction: {}", e))?; + .send_raw_transaction(&asset_lock_transaction)?; // Step 4: Remove used UTXOs from wallet { @@ -427,10 +442,12 @@ pub async fn shield_from_asset_lock( wallets .get(seed_hash) .cloned() - .ok_or_else(|| "Wallet not found".to_string())? + .ok_or(TaskError::WalletNotFound)? }; - let mut wallet = wallet_arc.write().map_err(|e| e.to_string())?; + let mut wallet = wallet_arc + .write() + .map_err(|_| TaskError::LockPoisoned { resource: "wallet" })?; wallet.utxos.retain(|_, utxo_map| { utxo_map.retain(|outpoint, _| !used_utxos.contains_key(outpoint)); !utxo_map.is_empty() @@ -439,16 +456,17 @@ pub async fn shield_from_asset_lock( for utxo in used_utxos.keys() { app_context .db - .drop_utxo(utxo, &app_context.network.to_string()) - .map_err(|e| e.to_string())?; + .drop_utxo(utxo, &app_context.network.to_string())?; } - wallet.recalculate_affected_address_balances(&used_utxos, app_context.as_ref())?; + wallet + .recalculate_affected_address_balances(&used_utxos, app_context.as_ref()) + .map_err(|e| TaskError::ShieldedTransitionBuildFailed { detail: e })?; } // Step 5: Wait for asset lock proof (InstantLock or ChainLock) with timeout let asset_lock_proof: AssetLockProof; - let timeout = tokio::time::sleep(Duration::from_secs(300)); // 5 minute timeout + let timeout = tokio::time::sleep(Duration::from_secs(300)); tokio::pin!(timeout); loop { @@ -470,7 +488,7 @@ pub async fn shield_from_asset_lock( }); } - return Err("Timeout waiting for asset lock proof — no InstantLock or ChainLock received within 5 minutes".to_string()); + return Err(TaskError::ShieldedAssetLockTimeout); } _ = tokio::time::sleep(Duration::from_millis(200)) => { let proofs = app_context.transactions_waiting_for_finality.lock().unwrap(); @@ -496,14 +514,13 @@ pub async fn shield_from_asset_lock( let recipient = payment_address_to_orchard(&shielded_state.keys.default_address); - // Shield only the user's requested amount; the extra duffs in the asset lock - // cover the platform processing fee. - let shield_amount_credits = amount_duffs.checked_mul(CREDITS_PER_DUFF).ok_or_else(|| { - format!( - "Overflow converting {} duffs to credits (CREDITS_PER_DUFF = {})", - amount_duffs, CREDITS_PER_DUFF - ) - })?; + let shield_amount_credits = + amount_duffs + .checked_mul(CREDITS_PER_DUFF) + .ok_or(TaskError::CreditCalculationOverflow { + amount: amount_duffs, + credits_per_duff: CREDITS_PER_DUFF, + })?; let state_transition = build_shield_from_asset_lock_transition( &recipient, @@ -515,12 +532,15 @@ pub async fn shield_from_asset_lock( [0u8; 36], sdk.version(), ) - .map_err(|e| format!("Failed to build shield-from-asset-lock transition: {e}"))?; + .map_err(|e| TaskError::ShieldedTransitionBuildFailed { + detail: e.to_string(), + })?; - state_transition - .broadcast(&sdk, None) - .await - .map_err(|e| format!("Failed to broadcast shield-from-asset-lock transition: {e}"))?; + state_transition.broadcast(&sdk, None).await.map_err(|e| { + TaskError::ShieldedBroadcastFailed { + source: Box::new(e), + } + })?; Ok(shield_amount_credits) } @@ -534,7 +554,7 @@ pub async fn shielded_withdrawal( shielded_state: &ShieldedWalletState, amount: u64, to_core_address: Address, -) -> Result, String> { +) -> Result, TaskError> { let sdk = { app_context.sdk.load().as_ref().clone() }; let proving_key = get_proving_key(); @@ -551,18 +571,24 @@ pub async fn shielded_withdrawal( .map(|note| { let merkle_path = tree .witness(note.position, 0) - .map_err(|e| format!("Failed to get Merkle witness: {e}"))? - .ok_or("No Merkle path available for note")?; + .map_err(|e| TaskError::ShieldedMerkleWitnessUnavailable { + detail: e.to_string(), + })? + .ok_or(TaskError::ShieldedMerkleWitnessUnavailable { + detail: "No Merkle path available for note".into(), + })?; Ok(SpendableNote { note: note.note, merkle_path, }) }) - .collect::, String>>()?; + .collect::, TaskError>>()?; let anchor = tree .anchor() - .map_err(|e| format!("Failed to get tree anchor: {e}"))?; + .map_err(|e| TaskError::ShieldedMerkleWitnessUnavailable { + detail: e.to_string(), + })?; (spends, anchor) }; @@ -582,12 +608,15 @@ pub async fn shielded_withdrawal( [0u8; 36], sdk.version(), ) - .map_err(|e| format!("Failed to build shielded withdrawal transition: {e}"))?; + .map_err(|e| TaskError::ShieldedTransitionBuildFailed { + detail: e.to_string(), + })?; - state_transition - .broadcast(&sdk, None) - .await - .map_err(|e| format!("Failed to broadcast shielded withdrawal transition: {e}"))?; + state_transition.broadcast(&sdk, None).await.map_err(|e| { + TaskError::ShieldedBroadcastFailed { + source: Box::new(e), + } + })?; Ok(spent_nullifiers) } @@ -596,19 +625,19 @@ pub async fn shielded_withdrawal( fn select_notes_for_amount( shielded_state: &ShieldedWalletState, amount: u64, -) -> Result<(Vec<&crate::model::wallet::shielded::ShieldedNote>, u64), String> { +) -> Result<(Vec<&crate::model::wallet::shielded::ShieldedNote>, u64), TaskError> { let unspent: Vec<_> = shielded_state.unspent_notes(); if unspent.is_empty() { - return Err("No unspent shielded notes available".to_string()); + return Err(TaskError::ShieldedNoUnspentNotes); } let total_available: u64 = unspent.iter().map(|n| n.value).sum(); if total_available < amount { - return Err(format!( - "Insufficient shielded balance: have {}, need {}", - total_available, amount - )); + return Err(TaskError::ShieldedInsufficientBalance { + available: total_available, + required: amount, + }); } let mut sorted: Vec<_> = unspent; diff --git a/src/backend_task/shielded/nullifiers.rs b/src/backend_task/shielded/nullifiers.rs index 9c6aeef7e..240ba5514 100644 --- a/src/backend_task/shielded/nullifiers.rs +++ b/src/backend_task/shielded/nullifiers.rs @@ -1,3 +1,4 @@ +use crate::backend_task::error::TaskError; use crate::context::AppContext; use crate::model::wallet::WalletSeedHash; use crate::model::wallet::shielded::ShieldedWalletState; @@ -15,7 +16,7 @@ pub async fn check_nullifiers( seed_hash: &WalletSeedHash, shielded_state: &mut ShieldedWalletState, network: Network, -) -> Result { +) -> Result { let sdk = { app_context.sdk.load().as_ref().clone() }; let network_str = network.to_string(); @@ -54,7 +55,9 @@ pub async fn check_nullifiers( last_sync_timestamp, ) .await - .map_err(|e| format!("Nullifier sync failed: {e}"))?; + .map_err(|e| TaskError::ShieldedNullifierSyncFailed { + detail: e.to_string(), + })?; // Mark found (spent) nullifiers let mut spent_count = 0u32; diff --git a/src/backend_task/shielded/sync.rs b/src/backend_task/shielded/sync.rs index 8c5531826..375f4e063 100644 --- a/src/backend_task/shielded/sync.rs +++ b/src/backend_task/shielded/sync.rs @@ -1,3 +1,4 @@ +use crate::backend_task::error::TaskError; use crate::context::AppContext; use crate::model::wallet::WalletSeedHash; use crate::model::wallet::shielded::{ShieldedNote, ShieldedWalletState}; @@ -19,7 +20,7 @@ pub async fn sync_notes( seed_hash: &WalletSeedHash, shielded_state: &mut ShieldedWalletState, network: Network, -) -> Result<(u32, u64), String> { +) -> Result<(u32, u64), TaskError> { let sdk = { app_context.sdk.load().as_ref().clone() }; let network_str = network.to_string(); @@ -37,7 +38,9 @@ pub async fn sync_notes( let result = sync_shielded_notes(&sdk, &prepared_ivk, aligned_start, None) .await - .map_err(|e| format!("Failed to sync shielded notes: {e}"))?; + .map_err(|e| TaskError::ShieldedSyncFailed { + detail: e.to_string(), + })?; tracing::info!( "Sync complete: total_scanned={}, decrypted={}, next_start_index={}", @@ -55,11 +58,14 @@ pub async fn sync_notes( continue; // already appended in a previous sync } - let cmx_bytes: [u8; 32] = raw_note - .cmx - .as_slice() - .try_into() - .map_err(|_| "Invalid cmx length")?; + let cmx_bytes: [u8; 32] = + raw_note + .cmx + .as_slice() + .try_into() + .map_err(|_| TaskError::ShieldedSyncFailed { + detail: "Invalid cmx length".into(), + })?; let is_ours = result .decrypted_notes @@ -76,7 +82,9 @@ pub async fn sync_notes( .lock() .unwrap() .append(cmx_bytes, retention) - .map_err(|e| format!("Failed to append note to tree: {e}"))?; + .map_err(|e| TaskError::ShieldedTreeUpdateFailed { + detail: e.to_string(), + })?; appended += 1; } @@ -88,7 +96,9 @@ pub async fn sync_notes( .lock() .unwrap() .checkpoint(checkpoint_id) - .map_err(|e| format!("Failed to checkpoint tree: {e}"))?; + .map_err(|e| TaskError::ShieldedTreeUpdateFailed { + detail: e.to_string(), + })?; } // Persist and record decrypted notes that are new (position >= already_have). diff --git a/src/context/shielded.rs b/src/context/shielded.rs index 38d816690..dcad9faff 100644 --- a/src/context/shielded.rs +++ b/src/context/shielded.rs @@ -1,6 +1,7 @@ use std::sync::OnceLock; use crate::backend_task::BackendTaskSuccessResult; +use crate::backend_task::error::TaskError; use crate::backend_task::shielded::ShieldedTask; use crate::context::AppContext; use crate::model::wallet::shielded::{ShieldedNote, ShieldedWalletState, derive_orchard_keys}; @@ -30,7 +31,7 @@ impl AppContext { pub async fn run_shielded_task( self: &Arc, task: ShieldedTask, - ) -> Result { + ) -> Result { match task { ShieldedTask::WarmUpProvingKey => { let _ = get_proving_key(); @@ -151,7 +152,7 @@ impl AppContext { fn initialize_shielded_wallet( self: &Arc, seed_hash: crate::model::wallet::WalletSeedHash, - ) -> Result { + ) -> Result { // Check if already initialized { let states = self.shielded_states.lock().unwrap(); @@ -167,28 +168,28 @@ impl AppContext { // Get the wallet seed let seed_bytes = { let wallets = self.wallets.read().unwrap(); - let wallet_arc = wallets.get(&seed_hash).ok_or("Wallet not found")?; + let wallet_arc = wallets.get(&seed_hash).ok_or(TaskError::WalletNotFound)?; let wallet = wallet_arc.read().unwrap(); match &wallet.wallet_seed { crate::model::wallet::WalletSeed::Open(open) => open.seed, crate::model::wallet::WalletSeed::Closed(_) => { - return Err("Wallet must be unlocked to initialize shielded state".to_string()); + return Err(TaskError::WalletLocked); } } }; - // Derive Orchard keys via ZIP32 - let keys = derive_orchard_keys(&seed_bytes, self.network, 0)?; + let keys = derive_orchard_keys(&seed_bytes, self.network, 0) + .map_err(|e| TaskError::ShieldedTransitionBuildFailed { detail: e })?; let network_str = self.network.to_string(); - // Open the persistent commitment tree on the shared DB connection. - // Tables are created automatically if they don't exist. let commitment_tree = ClientPersistentCommitmentTree::open_on_shared_connection( self.db.shared_connection(), 100, ) - .map_err(|e| format!("Failed to open commitment tree: {e}"))?; + .map_err(|e| TaskError::ShieldedTreeUpdateFailed { + detail: e.to_string(), + })?; let mut last_synced_index = 0u64; @@ -246,13 +247,11 @@ impl AppContext { async fn sync_shielded_notes( self: &Arc, seed_hash: crate::model::wallet::WalletSeedHash, - ) -> Result { + ) -> Result { // Take the state temporarily for the async operation let mut state = { let mut states = self.shielded_states.lock().unwrap(); - states - .remove(&seed_hash) - .ok_or("Shielded wallet not initialized")? + states.remove(&seed_hash).ok_or(TaskError::WalletNotFound)? }; let result = crate::backend_task::shielded::sync::sync_notes( @@ -292,12 +291,10 @@ impl AppContext { amount: u64, from_address: dash_sdk::dpp::address_funds::PlatformAddress, nonce_override: Option, - ) -> Result { + ) -> Result { let default_address = { let states = self.shielded_states.lock().unwrap(); - let state = states - .get(&seed_hash) - .ok_or("Shielded wallet not initialized")?; + let state = states.get(&seed_hash).ok_or(TaskError::WalletNotFound)?; state.keys.default_address }; @@ -323,12 +320,10 @@ impl AppContext { seed_hash: crate::model::wallet::WalletSeedHash, amount: u64, recipient_address_bytes: Vec, - ) -> Result { + ) -> Result { let mut state = { let mut states = self.shielded_states.lock().unwrap(); - states - .remove(&seed_hash) - .ok_or("Shielded wallet not initialized")? + states.remove(&seed_hash).ok_or(TaskError::WalletNotFound)? }; let result = crate::backend_task::shielded::bundle::shielded_transfer( @@ -361,12 +356,10 @@ impl AppContext { seed_hash: crate::model::wallet::WalletSeedHash, amount: u64, to_platform_address: dash_sdk::dpp::address_funds::PlatformAddress, - ) -> Result { + ) -> Result { let mut state = { let mut states = self.shielded_states.lock().unwrap(); - states - .remove(&seed_hash) - .ok_or("Shielded wallet not initialized")? + states.remove(&seed_hash).ok_or(TaskError::WalletNotFound)? }; let result = crate::backend_task::shielded::bundle::unshield_credits( @@ -399,12 +392,10 @@ impl AppContext { seed_hash: crate::model::wallet::WalletSeedHash, amount: u64, to_core_address: dash_sdk::dpp::dashcore::Address, - ) -> Result { + ) -> Result { let mut state = { let mut states = self.shielded_states.lock().unwrap(); - states - .remove(&seed_hash) - .ok_or("Shielded wallet not initialized")? + states.remove(&seed_hash).ok_or(TaskError::WalletNotFound)? }; let result = crate::backend_task::shielded::bundle::shielded_withdrawal( @@ -434,12 +425,10 @@ impl AppContext { self: &Arc, seed_hash: crate::model::wallet::WalletSeedHash, amount_duffs: u64, - ) -> Result { + ) -> Result { let state_ref = { let mut states = self.shielded_states.lock().unwrap(); - states - .remove(&seed_hash) - .ok_or("Shielded wallet not initialized")? + states.remove(&seed_hash).ok_or(TaskError::WalletNotFound)? }; let result = crate::backend_task::shielded::bundle::shield_from_asset_lock( @@ -467,12 +456,10 @@ impl AppContext { async fn check_nullifiers_task( self: &Arc, seed_hash: crate::model::wallet::WalletSeedHash, - ) -> Result { + ) -> Result { let mut state = { let mut states = self.shielded_states.lock().unwrap(); - states - .remove(&seed_hash) - .ok_or("Shielded wallet not initialized")? + states.remove(&seed_hash).ok_or(TaskError::WalletNotFound)? }; let result = crate::backend_task::shielded::nullifiers::check_nullifiers( diff --git a/src/ui/wallets/shield_credits_screen.rs b/src/ui/wallets/shield_credits_screen.rs index be2679b89..7519dd3e7 100644 --- a/src/ui/wallets/shield_credits_screen.rs +++ b/src/ui/wallets/shield_credits_screen.rs @@ -226,7 +226,6 @@ impl ShieldCreditsScreen { async move { *stage.lock().unwrap() = ShieldStage::BuildingProof { nonce }; - // build_shield_credit is sync (CPU-bound proof generation) let result = tokio::task::spawn_blocking(move || { bundle::build_shield_credit( &app_ctx, @@ -238,8 +237,8 @@ impl ShieldCreditsScreen { ) }) .await - .map_err(|e| format!("Build task panicked: {e}")) - .and_then(|r| r); + .map_err(|e| e.to_string()) + .and_then(|r| r.map_err(|e| e.to_string())); match &result { Ok(_) => { From 12b7c53b891f9f74a2e54bb067cc09ccb81ec181 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 23 Mar 2026 08:39:40 +0300 Subject: [PATCH 014/147] fix(ui): prevent settings password row from clipping right edge Reserve width for Save and Auto Update buttons so the password input doesn't consume all available space, pushing buttons off-screen. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/components/password_input.rs | 7 ++++++- src/ui/network_chooser_screen.rs | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/ui/components/password_input.rs b/src/ui/components/password_input.rs index 864e95a52..a433c147f 100644 --- a/src/ui/components/password_input.rs +++ b/src/ui/components/password_input.rs @@ -66,12 +66,17 @@ impl PasswordInput { self } - /// Set the desired width of the text field. + /// Set the desired width of the text field (builder pattern). pub fn with_desired_width(mut self, width: f32) -> Self { self.desired_width = Some(width); self } + /// Update the desired width of the text field on an existing instance. + pub fn set_desired_width(&mut self, width: f32) { + self.desired_width = Some(width); + } + /// Render the text in a monospace font (useful for WIF keys). pub fn with_monospace(mut self) -> Self { self.monospace = true; diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index 314d1b6b7..dc07949ec 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -444,6 +444,10 @@ impl NetworkChooserScreen { ui.add_space(8.0); ui.horizontal(|ui| { + // Reserve space for "Save" and "Auto Update" buttons + item spacing + let buttons_width = 200.0; + let input_width = (ui.available_width() - buttons_width).max(100.0); + self.dashmate_password_input.set_desired_width(input_width); self.dashmate_password_input.show(ui); let save_clicked = ui.button("Save").clicked(); From 08cecbb235111a98cbc7cefaac3f6c570c876da5 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Mar 2026 12:40:55 +0100 Subject: [PATCH 015/147] fix(rpc): include host:port in connection-refused errors and always show details Add CoreRpcConnectionFailed variant to TaskError that includes the configured address in the user-facing message. Connection-refused errors are now detected via is_rpc_connection_error() and enriched with host:port at every RPC call site where the URL is known (AppContext::rpc_error_with_url helper). Details panel is now shown for all RPC-related errors regardless of developer mode, so users can always see the technical information they need to diagnose connectivity issues. Co-Authored-By: Claude Opus 4.6 --- src/app.rs | 17 +++++++-- src/backend_task/core/mod.rs | 58 ++++++++++++++++++++++------ src/backend_task/error.rs | 74 ++++++++++++++++++++++++++++++++++++ src/context/mod.rs | 28 ++++++++++++-- 4 files changed, 157 insertions(+), 20 deletions(-) diff --git a/src/app.rs b/src/app.rs index c9f144b71..e81511234 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1244,11 +1244,20 @@ impl App for AppState { if !handled { let msg = err.to_string(); let handle = MessageBanner::set_global(ctx, &msg, MessageType::Error); - if self.current_app_context().is_developer_mode() { + // Always show details for RPC connection errors (users + // need the host:port info to diagnose). Show details + // for all other errors only in developer mode. + if matches!( + err, + TaskError::CoreRpc { .. } + | TaskError::CoreRpcConnectionFailed { .. } + | TaskError::CoreRpcAuthFailed + ) || 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). + // for RPC errors and 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); } self.visible_screen_mut() diff --git a/src/backend_task/core/mod.rs b/src/backend_task/core/mod.rs index d93209f07..6e47c6bd0 100644 --- a/src/backend_task/core/mod.rs +++ b/src/backend_task/core/mod.rs @@ -7,7 +7,7 @@ mod start_dash_qt; use crate::app_dir::core_cookie_path; use crate::backend_task::BackendTaskSuccessResult; -use crate::backend_task::error::{TaskError, is_rpc_auth_error}; +use crate::backend_task::error::{TaskError, is_rpc_auth_error, is_rpc_connection_error}; use crate::config::{Config, NetworkConfig}; use crate::context::AppContext; use crate::model::wallet::Wallet; @@ -178,7 +178,7 @@ impl AppContext { self.network, )) }) - .map_err(TaskError::from), + .map_err(|e| self.rpc_error_with_url(e)), CoreTask::GetBestChainLocks => { // Load configs let config = Config::load_from(&self.data_dir)?; @@ -195,19 +195,19 @@ impl AppContext { let devnet_result = Self::get_best_chain_lock(maybe_devnet_config, Network::Devnet); let local_result = Self::get_best_chain_lock(maybe_local_config, Network::Regtest); - // Surface auth errors on the active network instead of - // silently degrading to "Disconnected". - let active_result = match self.network { - Network::Mainnet => &mainnet_result, - Network::Testnet => &testnet_result, - Network::Devnet => &devnet_result, - Network::Regtest => &local_result, - _ => &mainnet_result, + // Surface auth and connection errors on the active network + // instead of silently degrading to "Disconnected". + let (active_result, active_config) = match self.network { + Network::Mainnet => (&mainnet_result, maybe_mainnet_config), + Network::Testnet => (&testnet_result, maybe_testnet_config), + Network::Devnet => (&devnet_result, maybe_devnet_config), + Network::Regtest => (&local_result, maybe_local_config), + _ => (&mainnet_result, maybe_mainnet_config), }; if let Err(e) = active_result - && is_rpc_auth_error(e) + && let Some(task_err) = Self::chain_lock_rpc_error(active_config, e) { - return Err(TaskError::CoreRpcAuthFailed); + return Err(task_err); } // Convert each to Option (flatten Ok(None) and Err into None) @@ -481,6 +481,40 @@ impl AppContext { client.get_best_chain_lock().map(Some) } + /// Convert a `dashcore_rpc::Error` from `get_best_chain_lock` into a + /// `TaskError`, enriching connection failures with host:port. + fn chain_lock_rpc_error( + config: &Option, + e: &dashcore_rpc::Error, + ) -> Option { + if is_rpc_auth_error(e) { + return Some(TaskError::CoreRpcAuthFailed); + } + if is_rpc_connection_error(e) { + let url = config + .as_ref() + .map(|c| format!("{}:{}", c.core_host, c.core_rpc_port)) + .unwrap_or_else(|| "unknown".to_string()); + // We can't move the error since we only have a reference, so we + // create the generic variant without a source. The user-facing + // message already contains the URL which is the actionable part. + return Some(TaskError::CoreRpcConnectionFailed { + url, + source: dashcore_rpc::Error::JsonRpc( + dashcore_rpc::jsonrpc::error::Error::Transport(Box::new( + dashcore_rpc::jsonrpc::simple_http::Error::SocketError( + std::io::Error::new( + std::io::ErrorKind::ConnectionRefused, + format!("{e}"), + ), + ), + )), + ), + }); + } + None + } + async fn send_wallet_payment( &self, wallet: Arc>, diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index cd544bac3..25c1f44e6 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -60,6 +60,16 @@ pub enum TaskError { #[error("Dash Core rejected your credentials. Check your RPC password in settings.")] CoreRpcAuthFailed, + /// Could not connect to Dash Core at the configured address. + #[error( + "Could not connect to Dash Core at {url}. Check that Dash Core is running and your network settings are correct." + )] + CoreRpcConnectionFailed { + url: String, + #[source] + source: dashcore_rpc::Error, + }, + /// A Dash Core RPC call failed. #[error("Could not communicate with Dash Core. Check that Dash Core is running and retry.")] CoreRpc { @@ -681,6 +691,21 @@ pub fn is_rpc_auth_error(e: &dashcore_rpc::Error) -> bool { false } +/// Returns `true` when the RPC error indicates a transport-level connection +/// failure (refused, reset, timeout) as opposed to a protocol-level error. +/// Excludes HTTP status code errors (like 401) which are auth, not connection. +pub fn is_rpc_connection_error(e: &dashcore_rpc::Error) -> bool { + if let dashcore_rpc::Error::JsonRpc(dashcore_rpc::jsonrpc::error::Error::Transport(boxed)) = e + && let Some(http_err) = boxed.downcast_ref::() + { + return matches!( + http_err, + dashcore_rpc::jsonrpc::simple_http::Error::SocketError(_) + ); + } + false +} + /// Returns `true` when the SDK error indicates an invalid instant asset lock /// proof signature — the structured equivalent of the old string-matching /// on `"Instant lock proof signature is invalid"`. @@ -1099,4 +1124,53 @@ mod tests { assert!(msg.contains("could not be verified instantly")); assert!(msg.contains("included in a block")); } + + #[test] + fn is_rpc_connection_error_detects_socket_error() { + let socket_err = dashcore_rpc::jsonrpc::simple_http::Error::SocketError( + std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "Connection refused"), + ); + let transport_err = dashcore_rpc::jsonrpc::error::Error::Transport(Box::new(socket_err)); + let rpc_err = dashcore_rpc::Error::JsonRpc(transport_err); + assert!(is_rpc_connection_error(&rpc_err)); + } + + #[test] + fn is_rpc_connection_error_ignores_http_error_codes() { + let http_err = dashcore_rpc::jsonrpc::simple_http::Error::HttpErrorCode(500); + let transport_err = dashcore_rpc::jsonrpc::error::Error::Transport(Box::new(http_err)); + let rpc_err = dashcore_rpc::Error::JsonRpc(transport_err); + assert!(!is_rpc_connection_error(&rpc_err)); + } + + #[test] + fn is_rpc_connection_error_ignores_rpc_errors() { + let rpc_err = dashcore_rpc::jsonrpc::error::RpcError { + code: -1, + message: "Some error".to_string(), + data: None, + }; + let err = dashcore_rpc::Error::JsonRpc(dashcore_rpc::jsonrpc::error::Error::Rpc(rpc_err)); + assert!(!is_rpc_connection_error(&err)); + } + + #[test] + fn connection_failed_display_includes_url() { + let socket_err = dashcore_rpc::jsonrpc::simple_http::Error::SocketError( + std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "Connection refused"), + ); + let err = TaskError::CoreRpcConnectionFailed { + url: "127.0.0.1:9998".to_string(), + source: dashcore_rpc::Error::JsonRpc(dashcore_rpc::jsonrpc::error::Error::Transport( + Box::new(socket_err), + )), + }; + let msg = err.to_string(); + assert!( + msg.contains("127.0.0.1:9998"), + "Expected URL in message, got: {msg}" + ); + assert!(msg.contains("Dash Core")); + assert!(msg.contains("network settings")); + } } diff --git a/src/context/mod.rs b/src/context/mod.rs index d81e3b7fc..c847e850a 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -9,7 +9,7 @@ mod wallet_lifecycle; pub(crate) use transaction_processing::get_transaction_info; use crate::app_dir::core_cookie_path; -use crate::backend_task::error::TaskError; +use crate::backend_task::error::{TaskError, is_rpc_connection_error}; use crate::components::core_zmq_listener::ZMQConnectionEvent; use crate::config::{Config, NetworkConfig}; use crate::context_provider::Provider as RpcProvider; @@ -614,9 +614,13 @@ impl AppContext { label: Option<&str>, ) -> Result<(), TaskError> { let client = self.core_client_for_wallet(core_wallet_name)?; - let info = client.get_address_info(address)?; + let info = client + .get_address_info(address) + .map_err(|e| self.rpc_error_with_url(e))?; if !(info.is_watchonly || info.is_mine) { - client.import_address(address, label, Some(false))?; + client + .import_address(address, label, Some(false)) + .map_err(|e| self.rpc_error_with_url(e))?; } Ok(()) } @@ -633,12 +637,28 @@ impl AppContext { } } + /// Convert an RPC error to `TaskError`, enriching connection failures with + /// the configured host:port so the user knows which address was unreachable. + pub(crate) fn rpc_error_with_url(&self, e: dash_sdk::dashcore_rpc::Error) -> TaskError { + if is_rpc_connection_error(&e) { + let url = self + .config + .read() + .ok() + .map(|c| format!("{}:{}", c.core_host, c.core_rpc_port)) + .unwrap_or_else(|| "unknown".to_string()); + TaskError::CoreRpcConnectionFailed { url, source: e } + } else { + TaskError::from(e) + } + } + /// List wallets currently loaded in Dash Core. pub fn list_core_wallets(&self) -> Result, TaskError> { let client = self.core_client_for_wallet(None)?; client .list_wallets() - .map_err(|e| TaskError::CoreRpc { source: e }) + .map_err(|e| self.rpc_error_with_url(e)) } /// Try to detect which loaded Core wallet owns the given address. From d51745fdc97ccf83edf2001d4bb2e489e4b9862e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Mar 2026 13:25:33 +0100 Subject: [PATCH 016/147] fix(ui): save RPC password for active network instead of hardcoded Regtest The Network Chooser screen had three bugs related to RPC password handling: 1. Password input was initialized from Regtest config only, ignoring the current network selection. 2. Password UI was hidden for all networks except Regtest, even though Mainnet/Testnet/Devnet also use RPC mode. 3. Save logic was hardcoded to update Regtest config and triggered a SwitchNetwork(Regtest) action, which disconnected the active network's ZMQ listener unnecessarily. Now the password input shows for any network in RPC mode, reads/writes the correct network config, reinits the RPC client in-place without triggering a network switch, and reloads the stored password when the user switches network tabs. The "Auto Update" (dashmate) button remains Regtest-only since dashmate is only relevant for local networks. Co-Authored-By: Claude Opus 4.6 --- src/ui/network_chooser_screen.rs | 73 +++++++++++++++++--------------- 1 file changed, 40 insertions(+), 33 deletions(-) diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index bf7770708..d1fa92a2f 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -126,9 +126,9 @@ impl NetworkChooserScreen { .with_char_limit(40) .with_desired_width(280.0); if let Ok(config) = Config::load_from(&mainnet_app_context.data_dir) - && let Some(local_config) = config.config_for_network(Network::Regtest) + && let Some(network_config) = config.config_for_network(current_network) { - dashmate_password_input.set_text(local_config.core_rpc_password.clone()); + dashmate_password_input.set_text(network_config.core_rpc_password.clone()); } let current_context = match current_network { @@ -371,6 +371,7 @@ impl NetworkChooserScreen { let response = ui.add_enabled_ui(!is_spv_connected, |ui| { network_combo.show_ui(ui, |ui| { + let prev_network = self.current_network; if ui .selectable_value( &mut self.current_network, @@ -414,6 +415,15 @@ impl NetworkChooserScreen { { app_action = AppAction::SwitchNetwork(Network::Regtest); } + if self.current_network != prev_network + && let Ok(config) = + Config::load_from(&self.mainnet_app_context.data_dir) + && let Some(network_config) = + config.config_for_network(self.current_network) + { + self.dashmate_password_input + .set_text(network_config.core_rpc_password.clone()); + } }); }); @@ -427,28 +437,27 @@ impl NetworkChooserScreen { ui.end_row(); }); - // Password input for Local network + // Password input for RPC mode (any network) let current_backend_mode = *self .backend_modes .entry(self.current_network) .or_insert(CoreBackendMode::Rpc); - if self.current_network == Network::Regtest - && current_backend_mode == CoreBackendMode::Rpc - { + if current_backend_mode == CoreBackendMode::Rpc { ui.add_space(20.0); ui.separator(); ui.add_space(12.0); ui.label( - egui::RichText::new("Local Network Password") + egui::RichText::new("Core RPC Password") .strong() .color(DashColors::text_primary(dark_mode)), ); ui.add_space(8.0); ui.horizontal(|ui| { - // Reserve space for "Save" and "Auto Update" buttons + item spacing - let buttons_width = 200.0; + // Reserve space for buttons + item spacing + let is_regtest = self.current_network == Network::Regtest; + let buttons_width = if is_regtest { 200.0 } else { 100.0 }; let input_width = (ui.available_width() - buttons_width).max(100.0); self.dashmate_password_input.set_desired_width(input_width); self.dashmate_password_input.show(ui); @@ -456,7 +465,7 @@ impl NetworkChooserScreen { let save_clicked = ui.button("Save").clicked(); let mut auto_update_succeeded = false; - if ui.button("Auto Update").clicked() { + if is_regtest && ui.button("Auto Update").clicked() { match read_dashmate_rpc_password("local_seed") { Ok(password) => { self.dashmate_password_input.set_text(password); @@ -472,40 +481,38 @@ impl NetworkChooserScreen { if (save_clicked || auto_update_succeeded) && let Ok(mut config) = Config::load_from(&self.mainnet_app_context.data_dir) - && let Some(local_cfg) = config.config_for_network(Network::Regtest).clone() + && let Some(network_cfg) = + config.config_for_network(self.current_network).clone() { - let updated_local_config = local_cfg.update_core_rpc_password( + let updated_config = network_cfg.update_core_rpc_password( self.dashmate_password_input.text().to_string(), ); config.update_config_for_network( - Network::Regtest, - updated_local_config.clone(), + self.current_network, + updated_config.clone(), ); if let Err(e) = config.save(&self.mainnet_app_context.data_dir) { tracing::error!("Failed to save config to .env: {e}"); } - // Update our local AppContext in memory - if let Some(local_app_context) = &self.local_app_context { - { - // Overwrite the config field with the new password - let mut cfg_lock = local_app_context.config.write().unwrap(); - *cfg_lock = updated_local_config; - } + let app_context = self.context_for_network(self.current_network); + { + let mut cfg_lock = app_context.config.write().unwrap(); + *cfg_lock = updated_config; + } - // Re-init the client & sdk from the updated config - if let Err(e) = - Arc::clone(local_app_context).reinit_core_client_and_sdk() - { - tracing::error!( - "Failed to re-init local RPC client and sdk: {}", - e - ); - } else { - // Trigger SwitchNetworks - app_action = AppAction::SwitchNetwork(Network::Regtest); - } + 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 + ); } + MessageBanner::set_global( + ui.ctx(), + "Core RPC password saved successfully.", + MessageType::Success, + ); } }); } From 227bbceb1e9300bf4a4d13eddfe0019eebcfec9c Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Mar 2026 13:32:53 +0100 Subject: [PATCH 017/147] fix(ui): ensure funding method dropdown fits all items without scrollbar Add explicit .height(200.0) to the funding method ComboBox in both top_up_identity_screen and add_new_identity_screen. The add_enabled_ui wrappers inflate item height via frame overhead, causing the popup to clip to a single row at the default height. Co-Authored-By: Claude Sonnet 4.6 --- src/ui/identities/add_new_identity_screen/mod.rs | 1 + src/ui/identities/top_up_identity_screen/mod.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/src/ui/identities/add_new_identity_screen/mod.rs b/src/ui/identities/add_new_identity_screen/mod.rs index 7605fe53f..edcf46404 100644 --- a/src/ui/identities/add_new_identity_screen/mod.rs +++ b/src/ui/identities/add_new_identity_screen/mod.rs @@ -433,6 +433,7 @@ impl AddNewIdentityScreen { ComboBox::from_id_salt("funding_method") .selected_text(format!("{}", *funding_method)) + .height(200.0) .show_ui(ui, |ui| { if ui .selectable_value( diff --git a/src/ui/identities/top_up_identity_screen/mod.rs b/src/ui/identities/top_up_identity_screen/mod.rs index f750a654f..4f00be289 100644 --- a/src/ui/identities/top_up_identity_screen/mod.rs +++ b/src/ui/identities/top_up_identity_screen/mod.rs @@ -242,6 +242,7 @@ impl TopUpIdentityScreen { ComboBox::from_id_salt("funding_method") .selected_text(format!("{}", *funding_method)) + .height(200.0) .show_ui(ui, |ui| { ui.selectable_value( &mut *funding_method, From 567e9becef48d25b3fb29e2f59b7f9f6027f57ca Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Mar 2026 13:36:03 +0100 Subject: [PATCH 018/147] fix(error): show actionable message for insufficient identity balance IdentityInsufficientBalanceError from the SDK now maps to a dedicated TaskError::IdentityInsufficientBalance variant instead of falling through to "An unexpected error occurred." The user sees the available and required credit amounts along with a clear "top up your identity" action. Co-Authored-By: Claude Opus 4.6 --- src/backend_task/error.rs | 101 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index 25c1f44e6..98f7fba20 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -317,6 +317,18 @@ pub enum TaskError { source_error: Box, }, + /// The identity doesn't have enough Platform credits for this operation. + #[error( + "Not enough Platform credits. Your identity has {available} credits \ + but this operation requires {required}. Please top up your identity first." + )] + IdentityInsufficientBalance { + available: u64, + required: u64, + #[source] + source_error: Box, + }, + /// Fetching address information from the platform failed. #[error("Could not retrieve address information from the platform. Please retry.")] PlatformFetchError { @@ -825,6 +837,7 @@ impl From for TaskError { DuplicateKeyId, ContractBoundsConflict(String), InvalidInstantLockProof, + InsufficientBalance { available: u64, required: u64 }, } let kind: Option = { @@ -849,6 +862,12 @@ impl From for TaskError { ) => Some(ConsensusKind::ContractBoundsConflict( e.contract_id().to_string(Encoding::Base58), )), + ConsensusError::StateError(StateError::IdentityInsufficientBalanceError(e)) => { + Some(ConsensusKind::InsufficientBalance { + available: e.balance(), + required: e.required_balance(), + }) + } ConsensusError::BasicError( BasicError::InvalidInstantAssetLockProofSignatureError(_), ) => Some(ConsensusKind::InvalidInstantLockProof), @@ -886,6 +905,14 @@ impl From for TaskError { source_error: boxed, } } + Some(ConsensusKind::InsufficientBalance { + available, + required, + }) => TaskError::IdentityInsufficientBalance { + available, + required, + source_error: boxed, + }, None => TaskError::SdkError { source_error: boxed, }, @@ -899,6 +926,7 @@ mod tests { 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; + use dash_sdk::dpp::consensus::state::identity::IdentityInsufficientBalanceError; use dash_sdk::dpp::consensus::state::identity::identity_public_key_already_exists_for_unique_contract_bounds_error::IdentityPublicKeyAlreadyExistsForUniqueContractBoundsError; use dash_sdk::dpp::identity::Purpose; use dash_sdk::platform::Identifier; @@ -1154,6 +1182,79 @@ mod tests { assert!(!is_rpc_connection_error(&err)); } + #[test] + fn from_sdk_error_insufficient_balance_via_consensus() { + let identity_id = Identifier::random(); + let consensus = ConsensusError::from(IdentityInsufficientBalanceError::new( + identity_id, + 12_656_420, + 42_332_820, + )); + let sdk_err = SdkError::from(consensus); + let err = TaskError::from(sdk_err); + assert!( + matches!( + err, + TaskError::IdentityInsufficientBalance { + available: 12_656_420, + required: 42_332_820, + .. + } + ), + "Expected IdentityInsufficientBalance, got: {err:?}" + ); + } + + #[test] + fn from_sdk_error_insufficient_balance_via_broadcast() { + let identity_id = Identifier::random(); + let consensus = + ConsensusError::from(IdentityInsufficientBalanceError::new(identity_id, 100, 500)); + let broadcast_err = dash_sdk::error::StateTransitionBroadcastError { + code: 40200, + message: "insufficient balance".to_string(), + cause: Some(consensus), + }; + let sdk_err = SdkError::StateTransitionBroadcastError(broadcast_err); + let err = TaskError::from(sdk_err); + assert!( + matches!( + err, + TaskError::IdentityInsufficientBalance { + available: 100, + required: 500, + .. + } + ), + "Expected IdentityInsufficientBalance, got: {err:?}" + ); + } + + #[test] + fn insufficient_balance_display_includes_amounts_and_action() { + let identity_id = Identifier::random(); + let consensus = ConsensusError::from(IdentityInsufficientBalanceError::new( + identity_id, + 12_656_420, + 42_332_820, + )); + let sdk_err = SdkError::from(consensus); + let err = TaskError::from(sdk_err); + let msg = err.to_string(); + assert!( + msg.contains("12656420"), + "Expected available balance in message, got: {msg}" + ); + assert!( + msg.contains("42332820"), + "Expected required balance in message, got: {msg}" + ); + assert!( + msg.contains("top up"), + "Expected actionable guidance in message, got: {msg}" + ); + } + #[test] fn connection_failed_display_includes_url() { let socket_err = dashcore_rpc::jsonrpc::simple_http::Error::SocketError( From a38c5948b2dc5fdb050903d6ee0c34c3f9257658 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:00:33 +0100 Subject: [PATCH 019/147] fix(ui): clear stale error banners when saving RPC password Co-Authored-By: Claude Opus 4.6 --- src/ui/network_chooser_screen.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index d1fa92a2f..3fdc3f216 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -508,6 +508,8 @@ impl NetworkChooserScreen { e ); } + // Clear stale auth/connection error banners before showing success + MessageBanner::clear_all_global(ui.ctx()); MessageBanner::set_global( ui.ctx(), "Core RPC password saved successfully.", From e6d72af589f216913c51fafa73ad2456de2122dd Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:11:51 +0100 Subject: [PATCH 020/147] fix: use network-compatible comparison for platform address lookups Regtest and Testnet share the `tdash` bech32m prefix, but `PlatformAddress::from_bech32m_string()` always returns `Network::Testnet` for `tdash` addresses. The strict `!=` comparison in the fund-platform dialog rejected valid Regtest addresses with "Address network mismatch". - Make `networks_address_compatible()` `pub(crate)` in `model::wallet` so all modules can reuse the canonical check - Remove the duplicate private copy in `backend_task::core` and import from the single source - Replace `network != self.app_context.network` in `dialogs.rs` with `networks_address_compatible()` so Testnet/Devnet/Regtest addresses are accepted interchangeably Co-Authored-By: Claude Opus 4.6 --- src/backend_task/core/mod.rs | 14 +------------- src/model/wallet/mod.rs | 2 +- src/ui/wallets/wallets_screen/dialogs.rs | 5 ++++- 3 files changed, 6 insertions(+), 15 deletions(-) diff --git a/src/backend_task/core/mod.rs b/src/backend_task/core/mod.rs index 6e47c6bd0..5ec259ce4 100644 --- a/src/backend_task/core/mod.rs +++ b/src/backend_task/core/mod.rs @@ -11,6 +11,7 @@ use crate::backend_task::error::{TaskError, is_rpc_auth_error, is_rpc_connection use crate::config::{Config, NetworkConfig}; use crate::context::AppContext; use crate::model::wallet::Wallet; +use crate::model::wallet::networks_address_compatible; use crate::model::wallet::single_key::SingleKeyWallet; use crate::spv::CoreBackendMode; use dash_sdk::dash_spv::sync::ProgressPercentage; @@ -39,19 +40,6 @@ use std::sync::{Arc, RwLock}; const DEFAULT_BIP44_ACCOUNT_INDEX: u32 = 0; -/// Check if two networks use the same address format. -/// Testnet, Devnet, and Regtest all use testnet-style addresses. -fn networks_address_compatible(a: &Network, b: &Network) -> bool { - matches!( - (a, b), - (Network::Mainnet, Network::Mainnet) - | ( - Network::Testnet | Network::Devnet | Network::Regtest, - Network::Testnet | Network::Devnet | Network::Regtest, - ) - ) -} - #[derive(Debug, Clone)] pub enum CoreTask { #[allow(dead_code)] // May be used for getting single chain lock diff --git a/src/model/wallet/mod.rs b/src/model/wallet/mod.rs index c0fe02425..2ccf99503 100644 --- a/src/model/wallet/mod.rs +++ b/src/model/wallet/mod.rs @@ -67,7 +67,7 @@ pub const DASH_BIP44_ACCOUNT_0_PATH_TESTNET: [ChildNumber; 3] = [ /// Check if two networks use the same address format. /// Testnet, Devnet, and Regtest all use testnet-style addresses. -fn networks_address_compatible(a: &Network, b: &Network) -> bool { +pub(crate) fn networks_address_compatible(a: &Network, b: &Network) -> bool { matches!( (a, b), (Network::Mainnet, Network::Mainnet) diff --git a/src/ui/wallets/wallets_screen/dialogs.rs b/src/ui/wallets/wallets_screen/dialogs.rs index 0041d3753..d2c81f583 100644 --- a/src/ui/wallets/wallets_screen/dialogs.rs +++ b/src/ui/wallets/wallets_screen/dialogs.rs @@ -1027,7 +1027,10 @@ impl WalletsBalancesScreen { match PlatformAddress::from_bech32m_string(selected_addr) { Ok((addr, network)) => { // Validate that address network matches app network - if network != self.app_context.network { + if !crate::model::wallet::networks_address_compatible( + &network, + &self.app_context.network, + ) { self.fund_platform_dialog.status = Some(format!( "Address network mismatch: address is for {:?} but app is on {:?}", network, self.app_context.network From e908b8226470bcf4cd714d1775d18d1d37bf7ecf Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:19:23 +0100 Subject: [PATCH 021/147] fix(error): add actionable messages for shielded fee and pool-size errors Co-Authored-By: Claude Opus 4.6 --- src/backend_task/error.rs | 299 +++++++++++++++++++++++++++- src/backend_task/shielded/bundle.rs | 77 +++---- src/context/shielded.rs | 6 +- 3 files changed, 331 insertions(+), 51 deletions(-) diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index 98f7fba20..f8c31e144 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -655,6 +655,17 @@ pub enum TaskError { #[error("Could not build the shielded transaction. Please retry.")] ShieldedTransitionBuildFailed { detail: String }, + /// The amount plus network fee exceeds the spendable shielded balance. + #[error( + "The amount plus the network fee ({fee_dash} Dash) exceeds your shielded balance. Reduce the amount or shield more credits.", + fee_dash = format_credits_as_dash(*.fee) + )] + ShieldedFeeExceedsBalance { + amount: u64, + fee: u64, + spendable: u64, + }, + /// Failed to broadcast a shielded state transition. #[error( "Could not broadcast the shielded transaction. Please check your connection and retry." @@ -664,6 +675,17 @@ pub enum TaskError { source: Box, }, + /// The shielded pool does not have enough notes for an outgoing transaction. + #[error( + "The shielded pool needs more participants before you can unshield. The pool has {current_count} notes but requires at least {minimum_required}. Please try again later as more users join the pool." + )] + ShieldedInsufficientPoolNotes { + current_count: u64, + minimum_required: u64, + #[source] + source_error: Box, + }, + /// Invalid recipient address for shielded transfer. #[error("The recipient shielded address is invalid. Please check the address and retry.")] ShieldedInvalidRecipientAddress, @@ -735,6 +757,89 @@ pub fn is_instant_lock_proof_invalid(error: &SdkError) -> bool { ) } +/// Format a credit amount as Dash with 4 decimal places. +/// +/// Credits use 10^11 as the conversion factor (not satoshis). +fn format_credits_as_dash(credits: u64) -> String { + let whole = credits / 100_000_000_000; + let frac = credits % 100_000_000_000; + // 4 decimal places: divide fractional part by 10^7 to get 4 digits + let four_digits = frac / 10_000_000; + format!("{whole}.{four_digits:04}") +} + +// TODO: Replace string parsing with a pre-check on amount + fee > spendable +// before calling the SDK builder, or wait for upstream to add a typed +// ProtocolError variant (currently ProtocolError::ShieldedBuildError(String)). + +/// Parse the "amount + fee exceeds spendable" pattern from DPP builder errors. +/// +/// Matches strings like: +/// "unshield amount 188000000000 + fee 180841600 = ... exceeds total spendable value 188000000000" +/// "transfer amount X + fee Y = Z exceeds total spendable value W" +/// "withdrawal amount X + fee Y = Z exceeds total spendable value W" +/// +/// Returns `(amount, fee, spendable)` on match. +fn parse_fee_exceeds_spendable(detail: &str) -> Option<(u64, u64, u64)> { + // Pattern: "{type} amount {A} + fee {F} = {sum} exceeds total spendable value {S}" + let amount_start = detail.find("amount ")? + 7; + let amount_end = detail[amount_start..].find(' ')? + amount_start; + let amount: u64 = detail[amount_start..amount_end].parse().ok()?; + + let fee_marker = detail.find("fee ")?; + let fee_start = fee_marker + 4; + let fee_end = detail[fee_start..].find(' ')? + fee_start; + let fee: u64 = detail[fee_start..fee_end].parse().ok()?; + + let spendable_marker = detail.find("exceeds total spendable value ")?; + let spendable_start = spendable_marker + 30; + let spendable: u64 = detail[spendable_start..].trim().parse().ok()?; + + Some((amount, fee, spendable)) +} + +/// Construct the appropriate `TaskError` for a shielded transition build failure. +/// +/// Parses the error string for the "fee exceeds spendable" pattern and returns +/// `ShieldedFeeExceedsBalance` when matched, falling back to +/// `ShieldedTransitionBuildFailed` otherwise. +pub fn shielded_build_error(detail: String) -> TaskError { + if let Some((amount, fee, spendable)) = parse_fee_exceeds_spendable(&detail) { + TaskError::ShieldedFeeExceedsBalance { + amount, + fee, + spendable, + } + } else { + TaskError::ShieldedTransitionBuildFailed { detail } + } +} + +/// Construct the appropriate `TaskError` for a shielded broadcast failure. +/// +/// Checks for `InsufficientPoolNotesError` in the SDK error chain and returns +/// `ShieldedInsufficientPoolNotes` when matched, falling back to +/// `ShieldedBroadcastFailed` otherwise. +pub fn shielded_broadcast_error(e: SdkError) -> TaskError { + let consensus_error = match &e { + SdkError::StateTransitionBroadcastError(broadcast_err) => broadcast_err.cause.as_ref(), + SdkError::Protocol(ProtocolError::ConsensusError(ce)) => Some(ce.as_ref()), + _ => None, + }; + if let Some(ConsensusError::StateError(StateError::InsufficientPoolNotesError(pool_err))) = + consensus_error + { + return TaskError::ShieldedInsufficientPoolNotes { + current_count: pool_err.current_count(), + minimum_required: pool_err.minimum_required(), + source_error: Box::new(e), + }; + } + TaskError::ShieldedBroadcastFailed { + source: Box::new(e), + } +} + /// Produce a user-friendly message for SPV subsystem errors. /// /// Inspects the specific `SpvError` variant to give actionable guidance. @@ -837,7 +942,14 @@ impl From for TaskError { DuplicateKeyId, ContractBoundsConflict(String), InvalidInstantLockProof, - InsufficientBalance { available: u64, required: u64 }, + InsufficientBalance { + available: u64, + required: u64, + }, + InsufficientPoolNotes { + current_count: u64, + minimum_required: u64, + }, } let kind: Option = { @@ -871,6 +983,12 @@ impl From for TaskError { ConsensusError::BasicError( BasicError::InvalidInstantAssetLockProofSignatureError(_), ) => Some(ConsensusKind::InvalidInstantLockProof), + ConsensusError::StateError(StateError::InsufficientPoolNotesError(e)) => { + Some(ConsensusKind::InsufficientPoolNotes { + current_count: e.current_count(), + minimum_required: e.minimum_required(), + }) + } _ => None, }) .or_else(|| { @@ -913,6 +1031,14 @@ impl From for TaskError { required, source_error: boxed, }, + Some(ConsensusKind::InsufficientPoolNotes { + current_count, + minimum_required, + }) => TaskError::ShieldedInsufficientPoolNotes { + current_count, + minimum_required, + source_error: boxed, + }, None => TaskError::SdkError { source_error: boxed, }, @@ -1274,4 +1400,175 @@ mod tests { assert!(msg.contains("Dash Core")); assert!(msg.contains("network settings")); } + + #[test] + fn parse_fee_exceeds_spendable_unshield() { + let detail = "Shielded transaction build error: unshield amount 188000000000 + fee 180841600 = 188180841600 exceeds total spendable value 188000000000"; + let result = parse_fee_exceeds_spendable(detail); + assert_eq!( + result, + Some((188_000_000_000, 180_841_600, 188_000_000_000)) + ); + } + + #[test] + fn parse_fee_exceeds_spendable_transfer() { + let detail = "transfer amount 500000000000 + fee 200000000 = 500200000000 exceeds total spendable value 400000000000"; + let result = parse_fee_exceeds_spendable(detail); + assert_eq!( + result, + Some((500_000_000_000, 200_000_000, 400_000_000_000)) + ); + } + + #[test] + fn parse_fee_exceeds_spendable_no_match() { + let detail = "some other error message"; + assert_eq!(parse_fee_exceeds_spendable(detail), None); + } + + #[test] + fn shielded_build_error_produces_fee_variant_on_match() { + let detail = "unshield amount 188000000000 + fee 180841600 = 188180841600 exceeds total spendable value 188000000000".to_string(); + let err = shielded_build_error(detail); + assert!( + matches!( + err, + TaskError::ShieldedFeeExceedsBalance { + amount: 188_000_000_000, + fee: 180_841_600, + spendable: 188_000_000_000, + } + ), + "Expected ShieldedFeeExceedsBalance, got: {err:?}" + ); + } + + #[test] + fn shielded_build_error_falls_back_on_no_match() { + let detail = "some other build error".to_string(); + let err = shielded_build_error(detail); + assert!( + matches!(err, TaskError::ShieldedTransitionBuildFailed { .. }), + "Expected ShieldedTransitionBuildFailed, got: {err:?}" + ); + } + + #[test] + fn shielded_fee_exceeds_balance_display_shows_dash() { + let err = TaskError::ShieldedFeeExceedsBalance { + amount: 188_000_000_000, + fee: 180_841_600, + spendable: 188_000_000_000, + }; + let msg = err.to_string(); + assert!( + msg.contains("0.0018"), + "Expected fee in Dash in message, got: {msg}" + ); + assert!( + msg.contains("Reduce the amount"), + "Expected actionable guidance, got: {msg}" + ); + } + + #[test] + fn format_credits_as_dash_basic() { + assert_eq!(format_credits_as_dash(100_000_000_000), "1.0000"); + assert_eq!(format_credits_as_dash(180_841_600), "0.0018"); + assert_eq!(format_credits_as_dash(0), "0.0000"); + assert_eq!(format_credits_as_dash(250_000_000_000), "2.5000"); + } + + #[test] + fn from_sdk_error_insufficient_pool_notes_via_consensus() { + use dash_sdk::dpp::consensus::state::shielded::insufficient_pool_notes_error::InsufficientPoolNotesError; + let consensus = ConsensusError::from(InsufficientPoolNotesError::new(14, 250)); + let sdk_err = SdkError::from(consensus); + let err = TaskError::from(sdk_err); + assert!( + matches!( + err, + TaskError::ShieldedInsufficientPoolNotes { + current_count: 14, + minimum_required: 250, + .. + } + ), + "Expected ShieldedInsufficientPoolNotes, got: {err:?}" + ); + } + + #[test] + fn from_sdk_error_insufficient_pool_notes_via_broadcast() { + use dash_sdk::dpp::consensus::state::shielded::insufficient_pool_notes_error::InsufficientPoolNotesError; + let consensus = ConsensusError::from(InsufficientPoolNotesError::new(14, 250)); + let broadcast_err = dash_sdk::error::StateTransitionBroadcastError { + code: 40300, + message: "insufficient pool notes".to_string(), + cause: Some(consensus), + }; + let sdk_err = SdkError::StateTransitionBroadcastError(broadcast_err); + let err = TaskError::from(sdk_err); + assert!( + matches!( + err, + TaskError::ShieldedInsufficientPoolNotes { + current_count: 14, + minimum_required: 250, + .. + } + ), + "Expected ShieldedInsufficientPoolNotes, got: {err:?}" + ); + } + + #[test] + fn insufficient_pool_notes_display_includes_counts() { + use dash_sdk::dpp::consensus::state::shielded::insufficient_pool_notes_error::InsufficientPoolNotesError; + let consensus = ConsensusError::from(InsufficientPoolNotesError::new(14, 250)); + let sdk_err = SdkError::from(consensus); + let err = TaskError::from(sdk_err); + let msg = err.to_string(); + assert!(msg.contains("14"), "Expected current count, got: {msg}"); + assert!(msg.contains("250"), "Expected minimum required, got: {msg}"); + assert!( + msg.contains("try again later"), + "Expected actionable guidance, got: {msg}" + ); + } + + #[test] + fn shielded_broadcast_error_detects_pool_notes() { + use dash_sdk::dpp::consensus::state::shielded::insufficient_pool_notes_error::InsufficientPoolNotesError; + let consensus = ConsensusError::from(InsufficientPoolNotesError::new(14, 250)); + let broadcast_err = dash_sdk::error::StateTransitionBroadcastError { + code: 40300, + message: "insufficient pool notes".to_string(), + cause: Some(consensus), + }; + let sdk_err = SdkError::StateTransitionBroadcastError(broadcast_err); + let err = shielded_broadcast_error(sdk_err); + assert!( + matches!( + err, + TaskError::ShieldedInsufficientPoolNotes { + current_count: 14, + minimum_required: 250, + .. + } + ), + "Expected ShieldedInsufficientPoolNotes, got: {err:?}" + ); + } + + #[test] + fn shielded_broadcast_error_falls_back_for_other_errors() { + let sdk_err = SdkError::Generic("some broadcast error".to_string()); + let err = shielded_broadcast_error(sdk_err); + assert!( + matches!(err, TaskError::ShieldedBroadcastFailed { .. }), + "Expected ShieldedBroadcastFailed, got: {err:?}" + ); + } } diff --git a/src/backend_task/shielded/bundle.rs b/src/backend_task/shielded/bundle.rs index b6337c8c6..3f04fa5d1 100644 --- a/src/backend_task/shielded/bundle.rs +++ b/src/backend_task/shielded/bundle.rs @@ -1,4 +1,4 @@ -use crate::backend_task::error::TaskError; +use crate::backend_task::error::{TaskError, shielded_broadcast_error, shielded_build_error}; use crate::context::AppContext; use crate::context::shielded::get_proving_key; use crate::model::wallet::WalletSeedHash; @@ -121,9 +121,7 @@ pub fn build_shield_credit( [0u8; 36], sdk.version(), ) - .map_err(|e| TaskError::ShieldedTransitionBuildFailed { - detail: e.to_string(), - }) + .map_err(|e| shielded_build_error(e.to_string())) } /// Build and broadcast a Shield transition (transparent -> shielded pool). @@ -196,20 +194,17 @@ pub async fn shield_credits( [0u8; 36], sdk.version(), ) - .map_err(|e| TaskError::ShieldedTransitionBuildFailed { - detail: e.to_string(), - })? + .map_err(|e| shielded_build_error(e.to_string()))? }; if let Some(s) = &stage { *s.lock().unwrap() = ShieldStage::Broadcasting; } - state_transition.broadcast(&sdk, None).await.map_err(|e| { - TaskError::ShieldedBroadcastFailed { - source: Box::new(e), - } - })?; + state_transition + .broadcast(&sdk, None) + .await + .map_err(shielded_broadcast_error)?; Ok(()) } @@ -283,15 +278,12 @@ pub async fn shielded_transfer( None, sdk.version(), ) - .map_err(|e| TaskError::ShieldedTransitionBuildFailed { - detail: e.to_string(), - })?; + .map_err(|e| shielded_build_error(e.to_string()))?; - state_transition.broadcast(&sdk, None).await.map_err(|e| { - TaskError::ShieldedBroadcastFailed { - source: Box::new(e), - } - })?; + state_transition + .broadcast(&sdk, None) + .await + .map_err(shielded_broadcast_error)?; Ok(spent_nullifiers) } @@ -359,15 +351,12 @@ pub async fn unshield_credits( None, sdk.version(), ) - .map_err(|e| TaskError::ShieldedTransitionBuildFailed { - detail: e.to_string(), - })?; + .map_err(|e| shielded_build_error(e.to_string()))?; - state_transition.broadcast(&sdk, None).await.map_err(|e| { - TaskError::ShieldedBroadcastFailed { - source: Box::new(e), - } - })?; + state_transition + .broadcast(&sdk, None) + .await + .map_err(shielded_broadcast_error)?; Ok(spent_nullifiers) } @@ -424,7 +413,7 @@ pub async fn shield_from_asset_lock( Err(_) => { wallet .reload_utxos(app_context.as_ref()) - .map_err(|e| TaskError::ShieldedTransitionBuildFailed { detail: e })?; + .map_err(shielded_build_error)?; let (tx, private_key, address, _change, utxos) = wallet .generic_asset_lock_transaction( @@ -433,7 +422,7 @@ pub async fn shield_from_asset_lock( asset_lock_duffs, false, ) - .map_err(|e| TaskError::ShieldedTransitionBuildFailed { detail: e })?; + .map_err(shielded_build_error)?; (tx, private_key, address, utxos) } } @@ -483,7 +472,7 @@ pub async fn shield_from_asset_lock( wallet .recalculate_affected_address_balances(&used_utxos, app_context.as_ref()) - .map_err(|e| TaskError::ShieldedTransitionBuildFailed { detail: e })?; + .map_err(shielded_build_error)?; } // Step 5: Wait for asset lock proof (InstantLock or ChainLock) with timeout @@ -554,15 +543,12 @@ pub async fn shield_from_asset_lock( [0u8; 36], sdk.version(), ) - .map_err(|e| TaskError::ShieldedTransitionBuildFailed { - detail: e.to_string(), - })?; + .map_err(|e| shielded_build_error(e.to_string()))?; - state_transition.broadcast(&sdk, None).await.map_err(|e| { - TaskError::ShieldedBroadcastFailed { - source: Box::new(e), - } - })?; + state_transition + .broadcast(&sdk, None) + .await + .map_err(shielded_broadcast_error)?; Ok(shield_amount_credits) } @@ -633,15 +619,12 @@ pub async fn shielded_withdrawal( None, sdk.version(), ) - .map_err(|e| TaskError::ShieldedTransitionBuildFailed { - detail: e.to_string(), - })?; + .map_err(|e| shielded_build_error(e.to_string()))?; - state_transition.broadcast(&sdk, None).await.map_err(|e| { - TaskError::ShieldedBroadcastFailed { - source: Box::new(e), - } - })?; + state_transition + .broadcast(&sdk, None) + .await + .map_err(shielded_broadcast_error)?; Ok(spent_nullifiers) } diff --git a/src/context/shielded.rs b/src/context/shielded.rs index dcad9faff..79874fa9b 100644 --- a/src/context/shielded.rs +++ b/src/context/shielded.rs @@ -1,7 +1,7 @@ use std::sync::OnceLock; use crate::backend_task::BackendTaskSuccessResult; -use crate::backend_task::error::TaskError; +use crate::backend_task::error::{TaskError, shielded_build_error}; use crate::backend_task::shielded::ShieldedTask; use crate::context::AppContext; use crate::model::wallet::shielded::{ShieldedNote, ShieldedWalletState, derive_orchard_keys}; @@ -178,8 +178,8 @@ impl AppContext { } }; - let keys = derive_orchard_keys(&seed_bytes, self.network, 0) - .map_err(|e| TaskError::ShieldedTransitionBuildFailed { detail: e })?; + let keys = + derive_orchard_keys(&seed_bytes, self.network, 0).map_err(shielded_build_error)?; let network_str = self.network.to_string(); From c91d53b9c8bdc626a491a6d451068febb8f566cc Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:09:36 +0100 Subject: [PATCH 022/147] fix(error): add actionable message for shielded anchor mismatch Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend_task/error.rs | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index f8c31e144..d3810fb02 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -655,6 +655,10 @@ pub enum TaskError { #[error("Could not build the shielded transaction. Please retry.")] ShieldedTransitionBuildFailed { detail: String }, + /// The shielded note witnesses are stale — the commitment tree changed since sync. + #[error("Your shielded notes are out of sync. Please sync your shielded wallet and retry.")] + ShieldedAnchorMismatch { detail: String }, + /// The amount plus network fee exceeds the spendable shielded balance. #[error( "The amount plus the network fee ({fee_dash} Dash) exceeds your shielded balance. Reduce the amount or shield more credits.", @@ -800,9 +804,10 @@ fn parse_fee_exceeds_spendable(detail: &str) -> Option<(u64, u64, u64)> { /// Construct the appropriate `TaskError` for a shielded transition build failure. /// -/// Parses the error string for the "fee exceeds spendable" pattern and returns -/// `ShieldedFeeExceedsBalance` when matched, falling back to -/// `ShieldedTransitionBuildFailed` otherwise. +/// Parses the error string for known patterns and returns a specific variant: +/// - `ShieldedFeeExceedsBalance` when the fee exceeds spendable balance, +/// - `ShieldedAnchorMismatch` when witnesses are stale, +/// - `ShieldedTransitionBuildFailed` otherwise. pub fn shielded_build_error(detail: String) -> TaskError { if let Some((amount, fee, spendable)) = parse_fee_exceeds_spendable(&detail) { TaskError::ShieldedFeeExceedsBalance { @@ -810,6 +815,8 @@ pub fn shielded_build_error(detail: String) -> TaskError { fee, spendable, } + } else if detail.contains("AnchorMismatch") { + TaskError::ShieldedAnchorMismatch { detail } } else { TaskError::ShieldedTransitionBuildFailed { detail } } @@ -1571,4 +1578,27 @@ mod tests { "Expected ShieldedBroadcastFailed, got: {err:?}" ); } + + #[test] + fn shielded_build_error_produces_anchor_mismatch_variant() { + let detail = + "Shielded transaction build error: failed to add spend: AnchorMismatch".to_string(); + let err = shielded_build_error(detail); + assert!( + matches!(err, TaskError::ShieldedAnchorMismatch { .. }), + "Expected ShieldedAnchorMismatch, got: {err:?}" + ); + } + + #[test] + fn shielded_anchor_mismatch_display() { + let err = TaskError::ShieldedAnchorMismatch { + detail: "test".into(), + }; + let msg = err.to_string(); + assert!( + msg.contains("out of sync"), + "Expected sync message, got: {msg}" + ); + } } From 1ac5292a008ded29247977024cd800970f6081ff Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:18:23 +0100 Subject: [PATCH 023/147] fix(shielded): auto-resync notes and retry on anchor mismatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When unshield_credits, shielded_transfer, or shielded_withdrawal fails with ShieldedAnchorMismatch (stale commitment tree witnesses), automatically sync notes once to update the tree and retry the operation. Only one resync attempt is made — if the retry also fails, the error propagates as-is. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/context/shielded.rs | 84 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/src/context/shielded.rs b/src/context/shielded.rs index 79874fa9b..96973389f 100644 --- a/src/context/shielded.rs +++ b/src/context/shielded.rs @@ -335,6 +335,33 @@ impl AppContext { ) .await; + // On anchor mismatch, sync notes once and retry + let result = if matches!(result, Err(TaskError::ShieldedAnchorMismatch { .. })) { + tracing::info!("Shielded anchor mismatch during transfer — syncing notes and retrying"); + crate::backend_task::shielded::sync::sync_notes( + self, + &seed_hash, + &mut state, + self.network, + ) + .await + .map_err(|e| { + tracing::warn!("Note sync after anchor mismatch failed: {e}"); + e + })?; + state.last_notes_synced_at = Some(std::time::Instant::now()); + crate::backend_task::shielded::bundle::shielded_transfer( + self, + &seed_hash, + &state, + amount, + &recipient_address_bytes, + ) + .await + } else { + result + }; + // On success, mark the spent notes immediately if let Ok(ref spent_nullifiers) = result { self.mark_notes_spent(&seed_hash, &mut state, spent_nullifiers); @@ -371,6 +398,33 @@ impl AppContext { ) .await; + // On anchor mismatch, sync notes once and retry + let result = if matches!(result, Err(TaskError::ShieldedAnchorMismatch { .. })) { + tracing::info!("Shielded anchor mismatch during unshield — syncing notes and retrying"); + crate::backend_task::shielded::sync::sync_notes( + self, + &seed_hash, + &mut state, + self.network, + ) + .await + .map_err(|e| { + tracing::warn!("Note sync after anchor mismatch failed: {e}"); + e + })?; + state.last_notes_synced_at = Some(std::time::Instant::now()); + crate::backend_task::shielded::bundle::unshield_credits( + self, + &seed_hash, + &state, + amount, + to_platform_address, + ) + .await + } else { + result + }; + // On success, mark the spent notes immediately if let Ok(ref spent_nullifiers) = result { self.mark_notes_spent(&seed_hash, &mut state, spent_nullifiers); @@ -398,6 +452,7 @@ impl AppContext { states.remove(&seed_hash).ok_or(TaskError::WalletNotFound)? }; + let to_core_address_clone = to_core_address.clone(); let result = crate::backend_task::shielded::bundle::shielded_withdrawal( self, &seed_hash, @@ -407,6 +462,35 @@ impl AppContext { ) .await; + // On anchor mismatch, sync notes once and retry + let result = if matches!(result, Err(TaskError::ShieldedAnchorMismatch { .. })) { + tracing::info!( + "Shielded anchor mismatch during withdrawal — syncing notes and retrying" + ); + crate::backend_task::shielded::sync::sync_notes( + self, + &seed_hash, + &mut state, + self.network, + ) + .await + .map_err(|e| { + tracing::warn!("Note sync after anchor mismatch failed: {e}"); + e + })?; + state.last_notes_synced_at = Some(std::time::Instant::now()); + crate::backend_task::shielded::bundle::shielded_withdrawal( + self, + &seed_hash, + &state, + amount, + to_core_address_clone, + ) + .await + } else { + result + }; + if let Ok(ref spent_nullifiers) = result { self.mark_notes_spent(&seed_hash, &mut state, spent_nullifiers); } From 56a1f2e90684b7e72a9b4ce1a1c4d3866b0382a4 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:42:00 +0100 Subject: [PATCH 024/147] fix(shielded): ensure shielded tables exist and log DB errors during init Three changes to fix empty shielded balance after app restart: 1. Defensive table creation in initialize(): after migrations complete, ensure shielded_notes and shielded_wallet_meta tables exist even if the DB version was already past v29/v30 from a prior build. Both methods use CREATE TABLE IF NOT EXISTS, so this is safe. 2. Log DB errors during shielded init: change silent if-let-Ok pattern to match/Err with tracing::warn, so missing-table errors are visible instead of silently producing empty note lists. 3. Safety net resync: when the commitment tree has been synced but no unspent notes were loaded from DB, clear the tree and reset last_synced_index to 0 so the auto-sync rediscovers all notes. Co-Authored-By: Claude Opus 4.6 --- src/context/shielded.rs | 57 +++++++++++++++++++++++++--------- src/database/initialization.rs | 10 ++++++ 2 files changed, 52 insertions(+), 15 deletions(-) diff --git a/src/context/shielded.rs b/src/context/shielded.rs index 96973389f..24e967e48 100644 --- a/src/context/shielded.rs +++ b/src/context/shielded.rs @@ -216,23 +216,50 @@ impl AppContext { }; // Load persisted notes from DB and reconstruct Note objects - if let Ok(note_rows) = self.db.get_unspent_shielded_notes(&seed_hash, &network_str) { - for row in note_rows { - if let Some(note) = crate::model::wallet::shielded::deserialize_note(&row.note_data) - && let Some(nullifier) = Nullifier::from_bytes(&row.nullifier).into_option() - { - state.notes.push(ShieldedNote { - note, - position: Position::from(row.position), - cmx: row.cmx, - nullifier, - block_height: row.block_height, - is_spent: false, - value: row.value, - }); + match self.db.get_unspent_shielded_notes(&seed_hash, &network_str) { + Ok(note_rows) => { + for row in note_rows { + if let Some(note) = + crate::model::wallet::shielded::deserialize_note(&row.note_data) + && let Some(nullifier) = Nullifier::from_bytes(&row.nullifier).into_option() + { + state.notes.push(ShieldedNote { + note, + position: Position::from(row.position), + cmx: row.cmx, + nullifier, + block_height: row.block_height, + is_spent: false, + value: row.value, + }); + } } + state.recalculate_balance(); } - state.recalculate_balance(); + Err(e) => { + tracing::warn!("Failed to load shielded notes from DB (table may be missing): {e}"); + } + } + + // Safety net: if the tree has been synced but we have no unspent notes + // in the DB, force a full resync from index 0. This handles the case + // where change notes from prior operations were only in memory and the + // app restarted before the next sync persisted them. + if state.last_synced_index > 0 && state.notes.is_empty() { + tracing::info!( + "Shielded init: tree synced to index {} but no unspent notes in DB — forcing full resync", + state.last_synced_index, + ); + let _ = self.db.clear_commitment_tree_tables(); + let fresh_tree = ClientPersistentCommitmentTree::open_on_shared_connection( + self.db.shared_connection(), + 100, + ) + .map_err(|e| TaskError::ShieldedTreeUpdateFailed { + detail: e.to_string(), + })?; + state.commitment_tree = std::sync::Mutex::new(fresh_tree); + state.last_synced_index = 0; } let balance = state.shielded_balance; diff --git a/src/database/initialization.rs b/src/database/initialization.rs index a57764e95..5031c0e0f 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -46,6 +46,16 @@ impl Database { } } + // Defensive: ensure shielded tables exist even if migrations were + // skipped (e.g., DB version was already past v29/v30 from a previous + // build that had different migration content for those versions). + // Safe because both methods use CREATE TABLE/INDEX IF NOT EXISTS. + { + let conn = self.conn.lock().unwrap(); + self.create_shielded_tables(&conn)?; + self.create_shielded_wallet_meta_table(&conn)?; + } + Ok(()) } From 2f991f2c785495a51d0044a0c5b77fc9ab6b93e4 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:53:41 +0100 Subject: [PATCH 025/147] fix(db): consolidate migrations v28-v32 into v33 Resolves version numbering collision between zk and v1.0-dev branches: the zk branch used v28 for shielded tables while v1.0-dev used v28 for contacts. After merging, users migrating from either branch could end up with missing tables depending on which version their DB was at. v33 runs all sub-migrations idempotently in one step, ensuring all tables exist regardless of prior migration history. Co-Authored-By: Claude Opus 4.6 --- src/database/initialization.rs | 36 +++++++++++----------------------- 1 file changed, 11 insertions(+), 25 deletions(-) diff --git a/src/database/initialization.rs b/src/database/initialization.rs index 5031c0e0f..51d1e85c3 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -4,7 +4,7 @@ use rusqlite::{Connection, params}; use std::fs; use std::path::Path; -pub const DEFAULT_DB_VERSION: u16 = 32; +pub const DEFAULT_DB_VERSION: u16 = 33; pub const DEFAULT_NETWORK: &str = "mainnet"; @@ -46,37 +46,23 @@ impl Database { } } - // Defensive: ensure shielded tables exist even if migrations were - // skipped (e.g., DB version was already past v29/v30 from a previous - // build that had different migration content for those versions). - // Safe because both methods use CREATE TABLE/INDEX IF NOT EXISTS. - { - let conn = self.conn.lock().unwrap(); - self.create_shielded_tables(&conn)?; - self.create_shielded_wallet_meta_table(&conn)?; - } - Ok(()) } fn apply_version_changes(&self, version: u16, tx: &Connection) -> rusqlite::Result<()> { match version { - 32 => { - self.rename_network_dash_to_mainnet(tx)?; - self.add_wallet_transaction_status_column(tx)?; - } - 31 => { - self.add_nullifier_sync_timestamp_column(tx)?; - } - 30 => { - self.create_shielded_wallet_meta_table(tx)?; - } - 29 => { - self.create_shielded_tables(tx)?; - } - 28 => { + // Versions 28-32 were consolidated into v33 to resolve migration + // numbering conflicts between the zk and v1.0-dev branches. + // If migrating from < 28, these are no-ops that just bump the version. + 28..=32 => {} + 33 => { self.add_core_wallet_name_column(tx)?; self.init_contacts_tables(tx)?; + self.create_shielded_tables(tx)?; + self.create_shielded_wallet_meta_table(tx)?; + self.add_nullifier_sync_timestamp_column(tx)?; + self.rename_network_dash_to_mainnet(tx)?; + self.add_wallet_transaction_status_column(tx)?; } 27 => { self.add_network_indexes(tx)?; From f0040f43dbb0a3b874c05742a7c22056e019eef7 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:53:41 +0100 Subject: [PATCH 026/147] fix(db): consolidate migrations v28-v32 into v33 Resolves version numbering collision between zk and v1.0-dev branches: the zk branch used v28 for shielded tables while v1.0-dev used v28 for contacts. After merging, users migrating from either branch could end up with missing tables depending on which version their DB was at. v33 runs all sub-migrations idempotently in one step, ensuring all tables exist regardless of prior migration history. Co-Authored-By: Claude Opus 4.6 --- src/database/initialization.rs | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/src/database/initialization.rs b/src/database/initialization.rs index a57764e95..51d1e85c3 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -4,7 +4,7 @@ use rusqlite::{Connection, params}; use std::fs; use std::path::Path; -pub const DEFAULT_DB_VERSION: u16 = 32; +pub const DEFAULT_DB_VERSION: u16 = 33; pub const DEFAULT_NETWORK: &str = "mainnet"; @@ -51,22 +51,18 @@ impl Database { fn apply_version_changes(&self, version: u16, tx: &Connection) -> rusqlite::Result<()> { match version { - 32 => { - self.rename_network_dash_to_mainnet(tx)?; - self.add_wallet_transaction_status_column(tx)?; - } - 31 => { - self.add_nullifier_sync_timestamp_column(tx)?; - } - 30 => { - self.create_shielded_wallet_meta_table(tx)?; - } - 29 => { - self.create_shielded_tables(tx)?; - } - 28 => { + // Versions 28-32 were consolidated into v33 to resolve migration + // numbering conflicts between the zk and v1.0-dev branches. + // If migrating from < 28, these are no-ops that just bump the version. + 28..=32 => {} + 33 => { self.add_core_wallet_name_column(tx)?; self.init_contacts_tables(tx)?; + self.create_shielded_tables(tx)?; + self.create_shielded_wallet_meta_table(tx)?; + self.add_nullifier_sync_timestamp_column(tx)?; + self.rename_network_dash_to_mainnet(tx)?; + self.add_wallet_transaction_status_column(tx)?; } 27 => { self.add_network_indexes(tx)?; From 19c49342fb258e872002ad869e85a221a3e236ad Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:02:17 +0100 Subject: [PATCH 027/147] chore: pin platform dependency to zk-fixes revision Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 67 ++++++++++++++++++++++-------------------------------- 1 file changed, 27 insertions(+), 40 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f7dd31f40..0f59a09e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1797,7 +1797,7 @@ dependencies = [ [[package]] name = "dapi-grpc" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#7a6d444bc5ba03c331d1748364afab1a021dbd4e" dependencies = [ "dash-platform-macros", "futures-core", @@ -1865,7 +1865,7 @@ dependencies = [ [[package]] name = "dash-context-provider" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#7a6d444bc5ba03c331d1748364afab1a021dbd4e" dependencies = [ "dpp", "drive", @@ -1947,7 +1947,7 @@ dependencies = [ [[package]] name = "dash-platform-macros" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#7a6d444bc5ba03c331d1748364afab1a021dbd4e" dependencies = [ "heck", "quote", @@ -1957,7 +1957,7 @@ dependencies = [ [[package]] name = "dash-sdk" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#7a6d444bc5ba03c331d1748364afab1a021dbd4e" dependencies = [ "arc-swap", "async-trait", @@ -2108,7 +2108,7 @@ dependencies = [ [[package]] name = "dashpay-contract" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#7a6d444bc5ba03c331d1748364afab1a021dbd4e" dependencies = [ "platform-value", "platform-version", @@ -2119,7 +2119,7 @@ dependencies = [ [[package]] name = "data-contracts" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#7a6d444bc5ba03c331d1748364afab1a021dbd4e" dependencies = [ "dashpay-contract", "dpns-contract", @@ -2372,7 +2372,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "dpns-contract" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#7a6d444bc5ba03c331d1748364afab1a021dbd4e" dependencies = [ "platform-value", "platform-version", @@ -2383,7 +2383,7 @@ dependencies = [ [[package]] name = "dpp" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#7a6d444bc5ba03c331d1748364afab1a021dbd4e" dependencies = [ "anyhow", "async-trait", @@ -2432,7 +2432,7 @@ dependencies = [ [[package]] name = "dpp-json-convertible-derive" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#7a6d444bc5ba03c331d1748364afab1a021dbd4e" dependencies = [ "proc-macro2", "quote", @@ -2442,7 +2442,7 @@ dependencies = [ [[package]] name = "drive" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#7a6d444bc5ba03c331d1748364afab1a021dbd4e" dependencies = [ "bincode 2.0.1", "byteorder", @@ -2467,7 +2467,7 @@ dependencies = [ [[package]] name = "drive-proof-verifier" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#7a6d444bc5ba03c331d1748364afab1a021dbd4e" dependencies = [ "bincode 2.0.1", "dapi-grpc", @@ -3051,7 +3051,7 @@ dependencies = [ [[package]] name = "feature-flags-contract" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#7a6d444bc5ba03c331d1748364afab1a021dbd4e" dependencies = [ "platform-value", "platform-version", @@ -4345,7 +4345,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.58.0", ] [[package]] @@ -4817,7 +4817,7 @@ dependencies = [ [[package]] name = "keyword-search-contract" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#7a6d444bc5ba03c331d1748364afab1a021dbd4e" dependencies = [ "platform-value", "platform-version", @@ -5023,7 +5023,7 @@ dependencies = [ [[package]] name = "masternode-reward-shares-contract" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#7a6d444bc5ba03c331d1748364afab1a021dbd4e" dependencies = [ "platform-value", "platform-version", @@ -6159,7 +6159,7 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "platform-encryption" version = "2.1.1" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#7a6d444bc5ba03c331d1748364afab1a021dbd4e" dependencies = [ "aes", "cbc", @@ -6170,7 +6170,7 @@ dependencies = [ [[package]] name = "platform-serialization" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#7a6d444bc5ba03c331d1748364afab1a021dbd4e" dependencies = [ "bincode 2.0.1", "platform-version", @@ -6179,7 +6179,7 @@ dependencies = [ [[package]] name = "platform-serialization-derive" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#7a6d444bc5ba03c331d1748364afab1a021dbd4e" dependencies = [ "proc-macro2", "quote", @@ -6190,7 +6190,7 @@ dependencies = [ [[package]] name = "platform-value" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#7a6d444bc5ba03c331d1748364afab1a021dbd4e" dependencies = [ "base64 0.22.1", "bincode 2.0.1", @@ -6210,7 +6210,7 @@ dependencies = [ [[package]] name = "platform-version" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#7a6d444bc5ba03c331d1748364afab1a021dbd4e" dependencies = [ "bincode 2.0.1", "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", @@ -6221,7 +6221,7 @@ dependencies = [ [[package]] name = "platform-versioning" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#7a6d444bc5ba03c331d1748364afab1a021dbd4e" dependencies = [ "proc-macro2", "quote", @@ -6425,7 +6425,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", @@ -6446,7 +6446,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", @@ -7006,7 +7006,7 @@ dependencies = [ [[package]] name = "rs-dapi-client" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#7a6d444bc5ba03c331d1748364afab1a021dbd4e" dependencies = [ "backon", "chrono", @@ -8191,7 +8191,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "token-history-contract" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#7a6d444bc5ba03c331d1748364afab1a021dbd4e" dependencies = [ "platform-value", "platform-version", @@ -8990,7 +8990,7 @@ dependencies = [ [[package]] name = "wallet-utils-contract" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#7a6d444bc5ba03c331d1748364afab1a021dbd4e" dependencies = [ "platform-value", "platform-version", @@ -9634,19 +9634,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" @@ -10404,7 +10391,7 @@ dependencies = [ [[package]] name = "withdrawals-contract" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#7a6d444bc5ba03c331d1748364afab1a021dbd4e" dependencies = [ "num_enum 0.5.11", "platform-value", From ab8a230d017f6aa8068de92a88d23fc97da759ed Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:14:23 +0100 Subject: [PATCH 028/147] =?UTF-8?q?fix(db):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20fresh=20schema,=20error=20propagation,=20rename=20c?= =?UTF-8?q?overage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - propagate SQL error in add_nullifier_sync_timestamp_column instead of swallowing it with unwrap_or(false) - add shielded_notes and shielded_wallet_meta to rename_network_dash_to_mainnet Note: Fix 1 (last_nullifier_sync_timestamp in CREATE TABLE) was already present on this branch. Co-Authored-By: Claude Opus 4.6 --- src/database/initialization.rs | 2 ++ src/database/shielded.rs | 12 +++++------- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/database/initialization.rs b/src/database/initialization.rs index 51d1e85c3..aba4d8d3a 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -952,6 +952,8 @@ impl Database { "wallet_transactions", "single_key_wallet", "token", + "shielded_notes", + "shielded_wallet_meta", ]; for table in tables { conn.execute( diff --git a/src/database/shielded.rs b/src/database/shielded.rs index 9e0a3e5a4..6b4f15fbb 100644 --- a/src/database/shielded.rs +++ b/src/database/shielded.rs @@ -207,13 +207,11 @@ impl Database { )?; if table_exists { - let has_column: bool = conn - .query_row( - "SELECT COUNT(*) FROM pragma_table_info('shielded_wallet_meta') WHERE name='last_nullifier_sync_timestamp'", - [], - |row| row.get::<_, i32>(0).map(|count| count > 0), - ) - .unwrap_or(false); + let has_column: bool = conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info('shielded_wallet_meta') WHERE name='last_nullifier_sync_timestamp'", + [], + |row| row.get::<_, i32>(0).map(|count| count > 0), + )?; if !has_column { conn.execute( From 6a36d5f95bd336aeefb88fc0738f093e8d088590 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:14:23 +0100 Subject: [PATCH 029/147] =?UTF-8?q?fix(db):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20fresh=20schema,=20error=20propagation,=20rename=20c?= =?UTF-8?q?overage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - propagate SQL error in add_nullifier_sync_timestamp_column instead of swallowing it with unwrap_or(false) - add shielded_notes and shielded_wallet_meta to rename_network_dash_to_mainnet Note: Fix 1 (last_nullifier_sync_timestamp in CREATE TABLE) was already present on this branch. Co-Authored-By: Claude Opus 4.6 --- src/database/initialization.rs | 2 ++ src/database/shielded.rs | 12 +++++------- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/database/initialization.rs b/src/database/initialization.rs index 51d1e85c3..aba4d8d3a 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -952,6 +952,8 @@ impl Database { "wallet_transactions", "single_key_wallet", "token", + "shielded_notes", + "shielded_wallet_meta", ]; for table in tables { conn.execute( diff --git a/src/database/shielded.rs b/src/database/shielded.rs index 9e0a3e5a4..6b4f15fbb 100644 --- a/src/database/shielded.rs +++ b/src/database/shielded.rs @@ -207,13 +207,11 @@ impl Database { )?; if table_exists { - let has_column: bool = conn - .query_row( - "SELECT COUNT(*) FROM pragma_table_info('shielded_wallet_meta') WHERE name='last_nullifier_sync_timestamp'", - [], - |row| row.get::<_, i32>(0).map(|count| count > 0), - ) - .unwrap_or(false); + let has_column: bool = conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info('shielded_wallet_meta') WHERE name='last_nullifier_sync_timestamp'", + [], + |row| row.get::<_, i32>(0).map(|count| count > 0), + )?; if !has_column { conn.execute( From f4efd64bdf13a423495134274a4274d1189fd7b6 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:18:18 +0100 Subject: [PATCH 030/147] fix(db): add foreign key constraints to shielded tables shielded_notes and shielded_wallet_meta both had wallet_seed_hash columns with no FK constraint. Added FOREIGN KEY (wallet_seed_hash) REFERENCES wallet(seed_hash) ON DELETE CASCADE to both, matching the pattern used by all other per-wallet tables in the codebase. Co-Authored-By: Claude Opus 4.6 --- src/database/shielded.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/database/shielded.rs b/src/database/shielded.rs index 6b4f15fbb..41ba1eddb 100644 --- a/src/database/shielded.rs +++ b/src/database/shielded.rs @@ -17,7 +17,8 @@ impl Database { is_spent INTEGER NOT NULL DEFAULT 0, value INTEGER NOT NULL, network TEXT NOT NULL, - UNIQUE(wallet_seed_hash, nullifier, network) + UNIQUE(wallet_seed_hash, nullifier, network), + FOREIGN KEY (wallet_seed_hash) REFERENCES wallet(seed_hash) ON DELETE CASCADE )", [], )?; @@ -188,7 +189,8 @@ impl Database { network TEXT NOT NULL, last_nullifier_sync_height INTEGER NOT NULL DEFAULT 0, last_nullifier_sync_timestamp INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (wallet_seed_hash, network) + PRIMARY KEY (wallet_seed_hash, network), + FOREIGN KEY (wallet_seed_hash) REFERENCES wallet(seed_hash) ON DELETE CASCADE )", [], )?; From 1e59763cff548c79490252153a5690b99a796d17 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:18:18 +0100 Subject: [PATCH 031/147] fix(db): add foreign key constraints to shielded tables shielded_notes and shielded_wallet_meta both had wallet_seed_hash columns with no FK constraint. Added FOREIGN KEY (wallet_seed_hash) REFERENCES wallet(seed_hash) ON DELETE CASCADE to both, matching the pattern used by all other per-wallet tables in the codebase. Co-Authored-By: Claude Opus 4.6 --- src/database/shielded.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/database/shielded.rs b/src/database/shielded.rs index 6b4f15fbb..41ba1eddb 100644 --- a/src/database/shielded.rs +++ b/src/database/shielded.rs @@ -17,7 +17,8 @@ impl Database { is_spent INTEGER NOT NULL DEFAULT 0, value INTEGER NOT NULL, network TEXT NOT NULL, - UNIQUE(wallet_seed_hash, nullifier, network) + UNIQUE(wallet_seed_hash, nullifier, network), + FOREIGN KEY (wallet_seed_hash) REFERENCES wallet(seed_hash) ON DELETE CASCADE )", [], )?; @@ -188,7 +189,8 @@ impl Database { network TEXT NOT NULL, last_nullifier_sync_height INTEGER NOT NULL DEFAULT 0, last_nullifier_sync_timestamp INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (wallet_seed_hash, network) + PRIMARY KEY (wallet_seed_hash, network), + FOREIGN KEY (wallet_seed_hash) REFERENCES wallet(seed_hash) ON DELETE CASCADE )", [], )?; From 08495bfba7cebf856d602e61aca23e9bc7b9779a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:58:13 +0100 Subject: [PATCH 032/147] fix(db): remove duplicate shielded methods after v1.0-dev merge On zk-fixes, shielded DB methods live in shielded.rs. The v1.0-dev backport inlined them in initialization.rs (no shielded.rs on that branch). Merging v1.0-dev back caused E0592 duplicate definitions. Co-Authored-By: Claude Opus 4.6 --- src/database/initialization.rs | 64 ++++------------------------------ 1 file changed, 6 insertions(+), 58 deletions(-) diff --git a/src/database/initialization.rs b/src/database/initialization.rs index a7ff419b8..08eb06ba6 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -937,64 +937,12 @@ impl Database { Ok(()) } - /// Create shielded pool tables. Idempotent (IF NOT EXISTS). - fn create_shielded_tables(&self, conn: &Connection) -> rusqlite::Result<()> { - conn.execute( - "CREATE TABLE IF NOT EXISTS shielded_notes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - wallet_seed_hash BLOB NOT NULL, - note_data BLOB NOT NULL, - position INTEGER NOT NULL, - cmx BLOB NOT NULL, - nullifier BLOB NOT NULL, - block_height INTEGER NOT NULL, - is_spent INTEGER NOT NULL DEFAULT 0, - value INTEGER NOT NULL, - network TEXT NOT NULL, - UNIQUE(wallet_seed_hash, nullifier, network), - FOREIGN KEY (wallet_seed_hash) REFERENCES wallet(seed_hash) ON DELETE CASCADE - )", - [], - )?; - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_shielded_notes_wallet_network - ON shielded_notes (wallet_seed_hash, network)", - [], - )?; - Ok(()) - } - - /// Create shielded wallet metadata table. Idempotent (IF NOT EXISTS). - fn create_shielded_wallet_meta_table(&self, conn: &Connection) -> rusqlite::Result<()> { - conn.execute( - "CREATE TABLE IF NOT EXISTS shielded_wallet_meta ( - wallet_seed_hash BLOB NOT NULL, - network TEXT NOT NULL, - last_nullifier_sync_height INTEGER NOT NULL DEFAULT 0, - last_nullifier_sync_timestamp INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (wallet_seed_hash, network), - FOREIGN KEY (wallet_seed_hash) REFERENCES wallet(seed_hash) ON DELETE CASCADE - )", - [], - )?; - Ok(()) - } - - /// Add last_nullifier_sync_timestamp column to shielded_wallet_meta. Idempotent. - fn add_nullifier_sync_timestamp_column(&self, conn: &Connection) -> rusqlite::Result<()> { - let has_column: bool = conn.query_row( - "SELECT COUNT(*) FROM pragma_table_info('shielded_wallet_meta') WHERE name='last_nullifier_sync_timestamp'", - [], - |row| row.get::<_, i32>(0).map(|count| count > 0), - )?; - if !has_column { - conn.execute( - "ALTER TABLE shielded_wallet_meta ADD COLUMN last_nullifier_sync_timestamp INTEGER NOT NULL DEFAULT 0", - [], - )?; - } - Ok(()) - } + // create_shielded_tables, create_shielded_wallet_meta_table, and + // add_nullifier_sync_timestamp_column live in shielded.rs (the canonical + // location on branches that have the shielded module). The v1.0-dev + // backport (PR #788) inlined them here because shielded.rs doesn't exist + // on that branch; after merging v1.0-dev back, the duplicates must be + // removed to avoid E0592. fn rename_network_dash_to_mainnet(&self, conn: &Connection) -> rusqlite::Result<()> { let tables = [ From c3af91a4d7044fab67d586ecd6404e42ad2a4ded Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:09:10 +0100 Subject: [PATCH 033/147] fix(test): remove duplicate wallet store in register_test_address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit register_test_address called db.store_wallet redundantly — callers already store the wallet before calling it, causing UNIQUE constraint violations when tests run in parallel on CI. Co-Authored-By: Claude Opus 4.6 --- src/model/wallet/mod.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/model/wallet/mod.rs b/src/model/wallet/mod.rs index 2ccf99503..6d7896b8e 100644 --- a/src/model/wallet/mod.rs +++ b/src/model/wallet/mod.rs @@ -3004,9 +3004,8 @@ mod tests { /// Helper: register a wallet address in the test database so that /// `update_address_balance` can find the row. + /// Caller must store the wallet first via `db.store_wallet()`. fn register_test_address(db: &Database, wallet: &Wallet, address: &Address) { - db.store_wallet(wallet, &Network::Testnet) - .expect("store test wallet"); let seed_hash = wallet.seed_hash(); let path = DerivationPath::from(vec![ ChildNumber::Hardened { index: 44 }, From 77bdd70fae8a88ff69db414db70d5f917cec1e22 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:38:31 +0100 Subject: [PATCH 034/147] =?UTF-8?q?fix(shielded):=20address=20PR=20review?= =?UTF-8?q?=20=E2=80=94=20error=20propagation,=20retry=20helper,=20safety?= =?UTF-8?q?=20net?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Propagate DB error from get_unspent_shielded_notes instead of swallowing - Extract with_anchor_retry() helper to deduplicate ~27 lines across shielded_transfer_task, unshield_credits_task, shielded_withdrawal_task - Fix safety net false positive: check all notes (spent + unspent) to distinguish "never had notes" from "all spent" - Propagate error from clear_commitment_tree_tables instead of discarding Co-Authored-By: Claude Opus 4.6 --- src/context/shielded.rs | 294 ++++++++++++++++------------------------ 1 file changed, 116 insertions(+), 178 deletions(-) diff --git a/src/context/shielded.rs b/src/context/shielded.rs index 24e967e48..ac14f3299 100644 --- a/src/context/shielded.rs +++ b/src/context/shielded.rs @@ -4,6 +4,7 @@ use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::error::{TaskError, shielded_build_error}; use crate::backend_task::shielded::ShieldedTask; use crate::context::AppContext; +use crate::model::wallet::WalletSeedHash; use crate::model::wallet::shielded::{ShieldedNote, ShieldedWalletState, derive_orchard_keys}; use dash_sdk::grovedb_commitment_tree::{ ClientPersistentCommitmentTree, Nullifier, Position, ProvingKey, @@ -102,7 +103,7 @@ impl AppContext { /// without needing a full platform-address sync. pub fn bump_platform_address_nonce( &self, - seed_hash: &crate::model::wallet::WalletSeedHash, + seed_hash: &WalletSeedHash, from_address: &dash_sdk::dpp::address_funds::PlatformAddress, ) { let wallets = self.wallets.read().unwrap(); @@ -142,7 +143,7 @@ impl AppContext { /// Get the default shielded payment address for a wallet. pub fn shielded_default_address( &self, - seed_hash: &crate::model::wallet::WalletSeedHash, + seed_hash: &WalletSeedHash, ) -> Option { let states = self.shielded_states.lock().unwrap(); states.get(seed_hash).map(|s| s.keys.default_address) @@ -151,7 +152,7 @@ impl AppContext { /// Initialize shielded wallet state by deriving ZIP32 keys from the wallet seed. fn initialize_shielded_wallet( self: &Arc, - seed_hash: crate::model::wallet::WalletSeedHash, + seed_hash: WalletSeedHash, ) -> Result { // Check if already initialized { @@ -216,50 +217,50 @@ impl AppContext { }; // Load persisted notes from DB and reconstruct Note objects - match self.db.get_unspent_shielded_notes(&seed_hash, &network_str) { - Ok(note_rows) => { - for row in note_rows { - if let Some(note) = - crate::model::wallet::shielded::deserialize_note(&row.note_data) - && let Some(nullifier) = Nullifier::from_bytes(&row.nullifier).into_option() - { - state.notes.push(ShieldedNote { - note, - position: Position::from(row.position), - cmx: row.cmx, - nullifier, - block_height: row.block_height, - is_spent: false, - value: row.value, - }); - } - } - state.recalculate_balance(); - } - Err(e) => { - tracing::warn!("Failed to load shielded notes from DB (table may be missing): {e}"); + let note_rows = self + .db + .get_unspent_shielded_notes(&seed_hash, &network_str)?; + for row in note_rows { + if let Some(note) = crate::model::wallet::shielded::deserialize_note(&row.note_data) + && let Some(nullifier) = Nullifier::from_bytes(&row.nullifier).into_option() + { + state.notes.push(ShieldedNote { + note, + position: Position::from(row.position), + cmx: row.cmx, + nullifier, + block_height: row.block_height, + is_spent: false, + value: row.value, + }); } } + state.recalculate_balance(); - // Safety net: if the tree has been synced but we have no unspent notes - // in the DB, force a full resync from index 0. This handles the case - // where change notes from prior operations were only in memory and the - // app restarted before the next sync persisted them. + // Safety net: if the tree has been synced but no notes exist at all + // (spent or unspent), force a full resync from index 0. This handles + // the case where change notes from prior operations were only in memory + // and the app restarted before the next sync persisted them. + // We check ALL notes (including spent) to avoid a false positive when + // the user legitimately spent everything. if state.last_synced_index > 0 && state.notes.is_empty() { - tracing::info!( - "Shielded init: tree synced to index {} but no unspent notes in DB — forcing full resync", - state.last_synced_index, - ); - let _ = self.db.clear_commitment_tree_tables(); - let fresh_tree = ClientPersistentCommitmentTree::open_on_shared_connection( - self.db.shared_connection(), - 100, - ) - .map_err(|e| TaskError::ShieldedTreeUpdateFailed { - detail: e.to_string(), - })?; - state.commitment_tree = std::sync::Mutex::new(fresh_tree); - state.last_synced_index = 0; + let all_notes = self.db.get_all_shielded_notes(&seed_hash, &network_str)?; + if all_notes.is_empty() { + tracing::info!( + "Shielded init: tree synced to index {} but no notes in DB — forcing full resync", + state.last_synced_index, + ); + self.db.clear_commitment_tree_tables()?; + let fresh_tree = ClientPersistentCommitmentTree::open_on_shared_connection( + self.db.shared_connection(), + 100, + ) + .map_err(|e| TaskError::ShieldedTreeUpdateFailed { + detail: e.to_string(), + })?; + state.commitment_tree = std::sync::Mutex::new(fresh_tree); + state.last_synced_index = 0; + } } let balance = state.shielded_balance; @@ -273,7 +274,7 @@ impl AppContext { /// Sync shielded notes from platform. async fn sync_shielded_notes( self: &Arc, - seed_hash: crate::model::wallet::WalletSeedHash, + seed_hash: WalletSeedHash, ) -> Result { // Take the state temporarily for the async operation let mut state = { @@ -314,7 +315,7 @@ impl AppContext { /// We read it without removing the state so parallel operations can share it. async fn shield_credits_task( self: &Arc, - seed_hash: crate::model::wallet::WalletSeedHash, + seed_hash: WalletSeedHash, amount: u64, from_address: dash_sdk::dpp::address_funds::PlatformAddress, nonce_override: Option, @@ -344,159 +345,104 @@ impl AppContext { /// Transfer credits within the shielded pool. async fn shielded_transfer_task( self: &Arc, - seed_hash: crate::model::wallet::WalletSeedHash, + seed_hash: WalletSeedHash, amount: u64, recipient_address_bytes: Vec, ) -> Result { - let mut state = { - let mut states = self.shielded_states.lock().unwrap(); - states.remove(&seed_hash).ok_or(TaskError::WalletNotFound)? - }; - - let result = crate::backend_task::shielded::bundle::shielded_transfer( - self, + self.with_anchor_retry( &seed_hash, - &state, - amount, - &recipient_address_bytes, + "transfer", + async |state: &ShieldedWalletState| { + crate::backend_task::shielded::bundle::shielded_transfer( + self, + &seed_hash, + state, + amount, + &recipient_address_bytes, + ) + .await + }, ) - .await; - - // On anchor mismatch, sync notes once and retry - let result = if matches!(result, Err(TaskError::ShieldedAnchorMismatch { .. })) { - tracing::info!("Shielded anchor mismatch during transfer — syncing notes and retrying"); - crate::backend_task::shielded::sync::sync_notes( - self, - &seed_hash, - &mut state, - self.network, - ) - .await - .map_err(|e| { - tracing::warn!("Note sync after anchor mismatch failed: {e}"); - e - })?; - state.last_notes_synced_at = Some(std::time::Instant::now()); - crate::backend_task::shielded::bundle::shielded_transfer( - self, - &seed_hash, - &state, - amount, - &recipient_address_bytes, - ) - .await - } else { - result - }; - - // On success, mark the spent notes immediately - if let Ok(ref spent_nullifiers) = result { - self.mark_notes_spent(&seed_hash, &mut state, spent_nullifiers); - } - - // Put state back - { - let mut states = self.shielded_states.lock().unwrap(); - states.insert(seed_hash, state); - } - - result?; + .await?; Ok(BackendTaskSuccessResult::ShieldedTransferComplete { seed_hash, amount }) } /// Unshield credits from the shielded pool to a platform address. async fn unshield_credits_task( self: &Arc, - seed_hash: crate::model::wallet::WalletSeedHash, + seed_hash: WalletSeedHash, amount: u64, to_platform_address: dash_sdk::dpp::address_funds::PlatformAddress, ) -> Result { - let mut state = { - let mut states = self.shielded_states.lock().unwrap(); - states.remove(&seed_hash).ok_or(TaskError::WalletNotFound)? - }; - - let result = crate::backend_task::shielded::bundle::unshield_credits( - self, + self.with_anchor_retry( &seed_hash, - &state, - amount, - to_platform_address, + "unshield", + async |state: &ShieldedWalletState| { + crate::backend_task::shielded::bundle::unshield_credits( + self, + &seed_hash, + state, + amount, + to_platform_address, + ) + .await + }, ) - .await; - - // On anchor mismatch, sync notes once and retry - let result = if matches!(result, Err(TaskError::ShieldedAnchorMismatch { .. })) { - tracing::info!("Shielded anchor mismatch during unshield — syncing notes and retrying"); - crate::backend_task::shielded::sync::sync_notes( - self, - &seed_hash, - &mut state, - self.network, - ) - .await - .map_err(|e| { - tracing::warn!("Note sync after anchor mismatch failed: {e}"); - e - })?; - state.last_notes_synced_at = Some(std::time::Instant::now()); - crate::backend_task::shielded::bundle::unshield_credits( - self, - &seed_hash, - &state, - amount, - to_platform_address, - ) - .await - } else { - result - }; - - // On success, mark the spent notes immediately - if let Ok(ref spent_nullifiers) = result { - self.mark_notes_spent(&seed_hash, &mut state, spent_nullifiers); - } - - // Put state back - { - let mut states = self.shielded_states.lock().unwrap(); - states.insert(seed_hash, state); - } - - result?; + .await?; Ok(BackendTaskSuccessResult::ShieldedCreditsUnshielded { seed_hash, amount }) } /// Withdraw credits from the shielded pool to a core L1 address. async fn shielded_withdrawal_task( self: &Arc, - seed_hash: crate::model::wallet::WalletSeedHash, + seed_hash: WalletSeedHash, amount: u64, to_core_address: dash_sdk::dpp::dashcore::Address, ) -> Result { + let to_core_address_clone = to_core_address.clone(); + self.with_anchor_retry( + &seed_hash, + "withdrawal", + async |state: &ShieldedWalletState| { + crate::backend_task::shielded::bundle::shielded_withdrawal( + self, + &seed_hash, + state, + amount, + to_core_address_clone.clone(), + ) + .await + }, + ) + .await?; + Ok(BackendTaskSuccessResult::ShieldedWithdrawalComplete { seed_hash, amount }) + } + + /// Run a shielded operation with automatic retry on anchor mismatch. + /// + /// Takes the shielded state from the map, calls `operation`, and if it + /// fails with `ShieldedAnchorMismatch`, syncs notes once and retries. + /// On success, marks spent notes and puts the state back. + async fn with_anchor_retry( + self: &Arc, + seed_hash: &WalletSeedHash, + operation_name: &str, + operation: impl AsyncFn(&ShieldedWalletState) -> Result, TaskError>, + ) -> Result, TaskError> { let mut state = { let mut states = self.shielded_states.lock().unwrap(); - states.remove(&seed_hash).ok_or(TaskError::WalletNotFound)? + states.remove(seed_hash).ok_or(TaskError::WalletNotFound)? }; - let to_core_address_clone = to_core_address.clone(); - let result = crate::backend_task::shielded::bundle::shielded_withdrawal( - self, - &seed_hash, - &state, - amount, - to_core_address, - ) - .await; + let result = operation(&state).await; - // On anchor mismatch, sync notes once and retry let result = if matches!(result, Err(TaskError::ShieldedAnchorMismatch { .. })) { tracing::info!( - "Shielded anchor mismatch during withdrawal — syncing notes and retrying" + "Shielded anchor mismatch during {operation_name} — syncing notes and retrying" ); crate::backend_task::shielded::sync::sync_notes( self, - &seed_hash, + seed_hash, &mut state, self.network, ) @@ -506,35 +452,27 @@ impl AppContext { e })?; state.last_notes_synced_at = Some(std::time::Instant::now()); - crate::backend_task::shielded::bundle::shielded_withdrawal( - self, - &seed_hash, - &state, - amount, - to_core_address_clone, - ) - .await + operation(&state).await } else { result }; if let Ok(ref spent_nullifiers) = result { - self.mark_notes_spent(&seed_hash, &mut state, spent_nullifiers); + self.mark_notes_spent(seed_hash, &mut state, spent_nullifiers); } { let mut states = self.shielded_states.lock().unwrap(); - states.insert(seed_hash, state); + states.insert(*seed_hash, state); } - result?; - Ok(BackendTaskSuccessResult::ShieldedWithdrawalComplete { seed_hash, amount }) + result } /// Shield core DASH directly into the shielded pool via asset lock. async fn shield_from_asset_lock_task( self: &Arc, - seed_hash: crate::model::wallet::WalletSeedHash, + seed_hash: WalletSeedHash, amount_duffs: u64, ) -> Result { let state_ref = { @@ -566,7 +504,7 @@ impl AppContext { /// Check nullifiers to detect spent notes. async fn check_nullifiers_task( self: &Arc, - seed_hash: crate::model::wallet::WalletSeedHash, + seed_hash: WalletSeedHash, ) -> Result { let mut state = { let mut states = self.shielded_states.lock().unwrap(); @@ -601,7 +539,7 @@ impl AppContext { /// Mark notes as spent in both memory and DB after a successful broadcast. fn mark_notes_spent( &self, - seed_hash: &crate::model::wallet::WalletSeedHash, + seed_hash: &WalletSeedHash, state: &mut ShieldedWalletState, spent_nullifiers: &[Nullifier], ) { From 67dfab79baa056234b05494c2c7371de1043372e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:38:40 +0100 Subject: [PATCH 035/147] =?UTF-8?q?fix(error):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20Amount=20formatting,=20Display=20completeness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Amount::dash_from_credits() constructor to model/amount.rs - Replace manual format_credits_as_dash() logic with Amount::dash_from_credits().to_string(), so credit amounts use Amount's Display (with DASH unit, trimmed trailing zeros) - Update ShieldedFeeExceedsBalance #[error] message: remove redundant "Dash" literal (Amount's Display already includes the "DASH" unit suffix) - Update format_credits_as_dash tests to match new output format ("1 DASH", "2.5 DASH", etc.) - Remove special-casing that showed with_details() for all RPC errors unconditionally: all required user-facing info is already in each variant's Display string; technical details are now shown only in developer mode (aligns with CLAUDE.md §error-messages rule 4) Co-Authored-By: Claude Opus 4.6 --- src/app.rs | 15 ++++----------- src/backend_task/error.rs | 20 +++++++------------- src/model/amount.rs | 7 +++++++ 3 files changed, 18 insertions(+), 24 deletions(-) diff --git a/src/app.rs b/src/app.rs index e81511234..573e6a75a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1244,18 +1244,11 @@ impl App for AppState { if !handled { let msg = err.to_string(); let handle = MessageBanner::set_global(ctx, &msg, MessageType::Error); - // Always show details for RPC connection errors (users - // need the host:port info to diagnose). Show details - // for all other errors only in developer mode. - if matches!( - err, - TaskError::CoreRpc { .. } - | TaskError::CoreRpcConnectionFailed { .. } - | TaskError::CoreRpcAuthFailed - ) || self.current_app_context().is_developer_mode() - { + // 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 - // for RPC errors and in developer mode. This is a local UI app — + // 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); diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index d3810fb02..1976ff40e 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -661,7 +661,7 @@ pub enum TaskError { /// The amount plus network fee exceeds the spendable shielded balance. #[error( - "The amount plus the network fee ({fee_dash} Dash) exceeds your shielded balance. Reduce the amount or shield more credits.", + "The amount plus the network fee ({fee_dash}) exceeds your shielded balance. Reduce the amount or shield more credits.", fee_dash = format_credits_as_dash(*.fee) )] ShieldedFeeExceedsBalance { @@ -761,15 +761,9 @@ pub fn is_instant_lock_proof_invalid(error: &SdkError) -> bool { ) } -/// Format a credit amount as Dash with 4 decimal places. -/// -/// Credits use 10^11 as the conversion factor (not satoshis). +/// Format a credit amount as Dash using `Amount`'s Display implementation. fn format_credits_as_dash(credits: u64) -> String { - let whole = credits / 100_000_000_000; - let frac = credits % 100_000_000_000; - // 4 decimal places: divide fractional part by 10^7 to get 4 digits - let four_digits = frac / 10_000_000; - format!("{whole}.{four_digits:04}") + crate::model::amount::Amount::dash_from_credits(credits).to_string() } // TODO: Replace string parsing with a pre-check on amount + fee > spendable @@ -1481,10 +1475,10 @@ mod tests { #[test] fn format_credits_as_dash_basic() { - assert_eq!(format_credits_as_dash(100_000_000_000), "1.0000"); - assert_eq!(format_credits_as_dash(180_841_600), "0.0018"); - assert_eq!(format_credits_as_dash(0), "0.0000"); - assert_eq!(format_credits_as_dash(250_000_000_000), "2.5000"); + assert_eq!(format_credits_as_dash(100_000_000_000), "1 DASH"); + assert_eq!(format_credits_as_dash(180_841_600), "0.001808416 DASH"); + assert_eq!(format_credits_as_dash(0), "0 DASH"); + assert_eq!(format_credits_as_dash(250_000_000_000), "2.5 DASH"); } #[test] diff --git a/src/model/amount.rs b/src/model/amount.rs index fdb5e9888..f97997ad8 100644 --- a/src/model/amount.rs +++ b/src/model/amount.rs @@ -324,6 +324,13 @@ impl Amount { Self::new(credits, DASH_DECIMAL_PLACES).with_unit_name("DASH") } + /// Return Amount representing Dash currency equal to the given credits. + /// + /// 1 DASH = 10^11 credits (100 billion). + pub fn dash_from_credits(credits: u64) -> Self { + Self::new(credits, DASH_DECIMAL_PLACES).with_unit_name("DASH") + } + /// Returns the DASH amount as duffs, rounded down to the nearest integer. /// /// ## Returns From 4b1de7bd8131127b7e374a2c731423b3162cf0b0 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:37:10 +0100 Subject: [PATCH 036/147] =?UTF-8?q?fix(rpc):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20context=20fallback,=20success=20banner,=20error=20s?= =?UTF-8?q?ource?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Guard in-memory config update + reinit behind a check that the target network's AppContext actually exists; skip both when it doesn't so we don't accidentally overwrite mainnet config via the fallback path. - Show success banner only when reinit succeeds; show a warning when it fails so the user knows the connection may not reflect the new password. - Make CoreRpcConnectionFailed.source Option so chain_lock_rpc_error can pass None instead of fabricating a fake ConnectionRefused error from a borrowed reference. Co-Authored-By: Claude Opus 4.6 --- src/backend_task/core/mod.rs | 17 +-------- src/backend_task/error.rs | 6 +-- src/context/mod.rs | 5 ++- src/ui/network_chooser_screen.rs | 64 +++++++++++++++++++++++--------- 4 files changed, 55 insertions(+), 37 deletions(-) diff --git a/src/backend_task/core/mod.rs b/src/backend_task/core/mod.rs index 5ec259ce4..fe3153e89 100644 --- a/src/backend_task/core/mod.rs +++ b/src/backend_task/core/mod.rs @@ -483,22 +483,7 @@ impl AppContext { .as_ref() .map(|c| format!("{}:{}", c.core_host, c.core_rpc_port)) .unwrap_or_else(|| "unknown".to_string()); - // We can't move the error since we only have a reference, so we - // create the generic variant without a source. The user-facing - // message already contains the URL which is the actionable part. - return Some(TaskError::CoreRpcConnectionFailed { - url, - source: dashcore_rpc::Error::JsonRpc( - dashcore_rpc::jsonrpc::error::Error::Transport(Box::new( - dashcore_rpc::jsonrpc::simple_http::Error::SocketError( - std::io::Error::new( - std::io::ErrorKind::ConnectionRefused, - format!("{e}"), - ), - ), - )), - ), - }); + return Some(TaskError::CoreRpcConnectionFailed { url, source: None }); } None } diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index 1976ff40e..3dc2a3bba 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -67,7 +67,7 @@ pub enum TaskError { CoreRpcConnectionFailed { url: String, #[source] - source: dashcore_rpc::Error, + source: Option, }, /// A Dash Core RPC call failed. @@ -1389,8 +1389,8 @@ mod tests { ); let err = TaskError::CoreRpcConnectionFailed { url: "127.0.0.1:9998".to_string(), - source: dashcore_rpc::Error::JsonRpc(dashcore_rpc::jsonrpc::error::Error::Transport( - Box::new(socket_err), + source: Some(dashcore_rpc::Error::JsonRpc( + dashcore_rpc::jsonrpc::error::Error::Transport(Box::new(socket_err)), )), }; let msg = err.to_string(); diff --git a/src/context/mod.rs b/src/context/mod.rs index c847e850a..3c0841a88 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -647,7 +647,10 @@ impl AppContext { .ok() .map(|c| format!("{}:{}", c.core_host, c.core_rpc_port)) .unwrap_or_else(|| "unknown".to_string()); - TaskError::CoreRpcConnectionFailed { url, source: e } + TaskError::CoreRpcConnectionFailed { + url, + source: Some(e), + } } else { TaskError::from(e) } diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index 3fdc3f216..c87c825f3 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -495,26 +495,56 @@ impl NetworkChooserScreen { tracing::error!("Failed to save config to .env: {e}"); } - let app_context = self.context_for_network(self.current_network); - { - let mut cfg_lock = app_context.config.write().unwrap(); - *cfg_lock = updated_config; - } + // Only update the in-memory config and reinit if the + // context for this network already exists. If it + // doesn't, `context_for_network` would silently fall + // back to mainnet and corrupt its config. The saved + // file-level config will be picked up when the network + // context is created. + let network_context_exists = match self.current_network { + Network::Mainnet => true, + Network::Testnet => self.testnet_app_context.is_some(), + Network::Devnet => self.devnet_app_context.is_some(), + Network::Regtest => self.local_app_context.is_some(), + _ => false, + }; + + if network_context_exists { + let app_context = self.context_for_network(self.current_network); + { + let mut cfg_lock = app_context.config.write().unwrap(); + *cfg_lock = updated_config; + } - 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 + // Clear stale auth/connection error banners before showing result + 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 + ); + 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, + ); + } else { + MessageBanner::set_global( + ui.ctx(), + "Core RPC password saved successfully.", + MessageType::Success, + ); + } + } else { + MessageBanner::set_global( + ui.ctx(), + "Core RPC password saved successfully.", + MessageType::Success, ); } - // Clear stale auth/connection error banners before showing success - MessageBanner::clear_all_global(ui.ctx()); - MessageBanner::set_global( - ui.ctx(), - "Core RPC password saved successfully.", - MessageType::Success, - ); } }); } From fe696569b022fe86636d7e6a2f5bade6a1f199c6 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Mar 2026 18:35:22 +0100 Subject: [PATCH 037/147] chore: simplify shielded helpers comment in initialization.rs Co-Authored-By: Claude Opus 4.6 --- src/database/initialization.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/database/initialization.rs b/src/database/initialization.rs index 08eb06ba6..1aab13df3 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -937,12 +937,8 @@ impl Database { Ok(()) } - // create_shielded_tables, create_shielded_wallet_meta_table, and - // add_nullifier_sync_timestamp_column live in shielded.rs (the canonical - // location on branches that have the shielded module). The v1.0-dev - // backport (PR #788) inlined them here because shielded.rs doesn't exist - // on that branch; after merging v1.0-dev back, the duplicates must be - // removed to avoid E0592. + // Shielded table helpers (create_shielded_tables, create_shielded_wallet_meta_table, + // add_nullifier_sync_timestamp_column) are implemented in database/shielded.rs. fn rename_network_dash_to_mainnet(&self, conn: &Connection) -> rusqlite::Result<()> { let tables = [ From 4d47ea4af2cad79a3f095d0efeaa3a4725814584 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Mar 2026 18:38:00 +0100 Subject: [PATCH 038/147] =?UTF-8?q?fix(db):=20address=20PR=20#789=20review?= =?UTF-8?q?=20=E2=80=94=20doc=20comments,=20migration=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split misplaced doc comment: `add_wallet_transaction_status_column` now has its own Migration 30 doc; `rename_network_dash_to_mainnet` gets its own Migration 29 doc. - Add inline comment explaining why the migration uses DEFAULT 2 (Confirmed) while fresh CREATE TABLE in wallet.rs uses DEFAULT 0. Co-Authored-By: Claude Opus 4.6 --- src/database/initialization.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/database/initialization.rs b/src/database/initialization.rs index a7ff419b8..193fa2b0f 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -914,14 +914,7 @@ impl Database { Ok(()) } - /// Migration 29: rename network value "dash" to "mainnet" in all tables. - /// - /// Upstream `dashcore` renamed `Network::Dash` to `Network::Mainnet`, - /// which changes `Display`/`FromStr` representations from `"dash"` to - /// `"mainnet"`. This migration updates every table that stores the - /// network as a string. /// Migration 30: add `status` column to `wallet_transactions`. - /// Default 2 (Confirmed) — all pre-existing rows were confirmed transactions. fn add_wallet_transaction_status_column(&self, conn: &Connection) -> rusqlite::Result<()> { let has_status: bool = conn.query_row( "SELECT COUNT(*) FROM pragma_table_info('wallet_transactions') WHERE name='status'", @@ -930,6 +923,9 @@ impl Database { )?; if !has_status { conn.execute( + // DEFAULT 2 (Confirmed) for migration: existing transactions predate status + // tracking and are assumed confirmed. Fresh installs use DEFAULT 0 (Unconfirmed) + // in the CREATE TABLE (wallet.rs). "ALTER TABLE wallet_transactions ADD COLUMN status INTEGER NOT NULL DEFAULT 2", [], )?; @@ -996,6 +992,11 @@ impl Database { Ok(()) } + /// Migration 29: rename network value `"dash"` to `"mainnet"` in all tables. + /// + /// Upstream `dashcore` renamed `Network::Dash` to `Network::Mainnet`, + /// changing the `Display`/`FromStr` representation. This migration updates + /// every table that stores the network as a string column. fn rename_network_dash_to_mainnet(&self, conn: &Connection) -> rusqlite::Result<()> { let tables = [ "settings", From ddea453e417d16a5cf06432f9546070144682376 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:58:13 +0100 Subject: [PATCH 039/147] fix(db): remove duplicate shielded methods after v1.0-dev merge On zk-fixes, shielded DB methods live in shielded.rs. The v1.0-dev backport inlined them in initialization.rs (no shielded.rs on that branch). Merging v1.0-dev back caused E0592 duplicate definitions. Co-Authored-By: Claude Opus 4.6 --- src/database/initialization.rs | 64 ++++------------------------------ 1 file changed, 6 insertions(+), 58 deletions(-) diff --git a/src/database/initialization.rs b/src/database/initialization.rs index 193fa2b0f..6d544afb6 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -933,64 +933,12 @@ impl Database { Ok(()) } - /// Create shielded pool tables. Idempotent (IF NOT EXISTS). - fn create_shielded_tables(&self, conn: &Connection) -> rusqlite::Result<()> { - conn.execute( - "CREATE TABLE IF NOT EXISTS shielded_notes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - wallet_seed_hash BLOB NOT NULL, - note_data BLOB NOT NULL, - position INTEGER NOT NULL, - cmx BLOB NOT NULL, - nullifier BLOB NOT NULL, - block_height INTEGER NOT NULL, - is_spent INTEGER NOT NULL DEFAULT 0, - value INTEGER NOT NULL, - network TEXT NOT NULL, - UNIQUE(wallet_seed_hash, nullifier, network), - FOREIGN KEY (wallet_seed_hash) REFERENCES wallet(seed_hash) ON DELETE CASCADE - )", - [], - )?; - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_shielded_notes_wallet_network - ON shielded_notes (wallet_seed_hash, network)", - [], - )?; - Ok(()) - } - - /// Create shielded wallet metadata table. Idempotent (IF NOT EXISTS). - fn create_shielded_wallet_meta_table(&self, conn: &Connection) -> rusqlite::Result<()> { - conn.execute( - "CREATE TABLE IF NOT EXISTS shielded_wallet_meta ( - wallet_seed_hash BLOB NOT NULL, - network TEXT NOT NULL, - last_nullifier_sync_height INTEGER NOT NULL DEFAULT 0, - last_nullifier_sync_timestamp INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (wallet_seed_hash, network), - FOREIGN KEY (wallet_seed_hash) REFERENCES wallet(seed_hash) ON DELETE CASCADE - )", - [], - )?; - Ok(()) - } - - /// Add last_nullifier_sync_timestamp column to shielded_wallet_meta. Idempotent. - fn add_nullifier_sync_timestamp_column(&self, conn: &Connection) -> rusqlite::Result<()> { - let has_column: bool = conn.query_row( - "SELECT COUNT(*) FROM pragma_table_info('shielded_wallet_meta') WHERE name='last_nullifier_sync_timestamp'", - [], - |row| row.get::<_, i32>(0).map(|count| count > 0), - )?; - if !has_column { - conn.execute( - "ALTER TABLE shielded_wallet_meta ADD COLUMN last_nullifier_sync_timestamp INTEGER NOT NULL DEFAULT 0", - [], - )?; - } - Ok(()) - } + // create_shielded_tables, create_shielded_wallet_meta_table, and + // add_nullifier_sync_timestamp_column live in shielded.rs (the canonical + // location on branches that have the shielded module). The v1.0-dev + // backport (PR #788) inlined them here because shielded.rs doesn't exist + // on that branch; after merging v1.0-dev back, the duplicates must be + // removed to avoid E0592. /// Migration 29: rename network value `"dash"` to `"mainnet"` in all tables. /// From 0d4e35957ca3e3bad06adfda8464ae9034ac9897 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Mar 2026 18:35:22 +0100 Subject: [PATCH 040/147] chore: simplify shielded helpers comment in initialization.rs Co-Authored-By: Claude Opus 4.6 --- src/database/initialization.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/database/initialization.rs b/src/database/initialization.rs index 6d544afb6..512214be9 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -933,12 +933,8 @@ impl Database { Ok(()) } - // create_shielded_tables, create_shielded_wallet_meta_table, and - // add_nullifier_sync_timestamp_column live in shielded.rs (the canonical - // location on branches that have the shielded module). The v1.0-dev - // backport (PR #788) inlined them here because shielded.rs doesn't exist - // on that branch; after merging v1.0-dev back, the duplicates must be - // removed to avoid E0592. + // Shielded table helpers (create_shielded_tables, create_shielded_wallet_meta_table, + // add_nullifier_sync_timestamp_column) are implemented in database/shielded.rs. /// Migration 29: rename network value `"dash"` to `"mainnet"` in all tables. /// From b0df68b497fb8acae15213d9f20ea06f1da04fe7 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Mar 2026 18:40:15 +0100 Subject: [PATCH 041/147] chore(db): document migration DEFAULT 2 vs fresh DEFAULT 0 Existing transactions predate status tracking and are assumed confirmed (DEFAULT 2). Fresh installs use DEFAULT 0 (Unconfirmed). Co-Authored-By: Claude Opus 4.6 --- src/database/initialization.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/database/initialization.rs b/src/database/initialization.rs index 1aab13df3..3744bd793 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -929,6 +929,9 @@ impl Database { |row| row.get::<_, i32>(0).map(|count| count > 0), )?; if !has_status { + // DEFAULT 2 (Confirmed) for migration: existing transactions predate status + // tracking and are assumed confirmed. Fresh installs use DEFAULT 0 (Unconfirmed) + // in the CREATE TABLE (wallet.rs). conn.execute( "ALTER TABLE wallet_transactions ADD COLUMN status INTEGER NOT NULL DEFAULT 2", [], From d563250878611cce79c7068b6f1ce2ac4ffef37c Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Mar 2026 18:45:32 +0100 Subject: [PATCH 042/147] =?UTF-8?q?fix(wallet):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20zero-balance=20detection,=20status=20warning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `spv_balance_known: bool` to `Wallet` to distinguish a synced zero-balance wallet from an unsynced one. `spv_confirmed_balance()` now returns `None` only before the first SPV report, and `Some(0)` after SPV confirms an empty wallet. Add `tracing::warn!` in `TransactionStatus::from_u8` when an unknown discriminant is encountered, avoiding silent data coercion in a financial context. Co-Authored-By: Claude Opus 4.6 --- src/database/wallet.rs | 1 + src/model/wallet/mod.rs | 36 ++++++++++++++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/database/wallet.rs b/src/database/wallet.rs index bbd7781c2..841a5221c 100644 --- a/src/database/wallet.rs +++ b/src/database/wallet.rs @@ -580,6 +580,7 @@ impl Database { confirmed_balance: confirmed_balance as u64, unconfirmed_balance: unconfirmed_balance as u64, total_balance: total_balance as u64, + spv_balance_known: false, platform_address_info: BTreeMap::new(), core_wallet_name, }, diff --git a/src/model/wallet/mod.rs b/src/model/wallet/mod.rs index 6d7896b8e..5db93cb33 100644 --- a/src/model/wallet/mod.rs +++ b/src/model/wallet/mod.rs @@ -363,6 +363,9 @@ pub struct Wallet { pub confirmed_balance: u64, pub unconfirmed_balance: u64, pub total_balance: u64, + /// True once SPV has reported balances at least once; distinguishes synced + /// zero-balance from not-yet-synced. + pub spv_balance_known: bool, /// DIP-17: Platform address balances and nonces (keyed by Core Address for lookup) pub platform_address_info: BTreeMap, /// Dash Core wallet name for multi-wallet RPC calls @@ -443,6 +446,7 @@ impl Wallet { confirmed_balance: 0, unconfirmed_balance: 0, total_balance: 0, + spv_balance_known: false, platform_address_info: Default::default(), core_wallet_name: None, }) @@ -538,7 +542,10 @@ impl TransactionStatus { 1 => Self::InstantSendLocked, 2 => Self::Confirmed, 3 => Self::ChainLocked, - _ => Self::Unconfirmed, + _ => { + tracing::warn!("Unknown TransactionStatus value {v}, defaulting to Unconfirmed"); + Self::Unconfirmed + } } } @@ -755,7 +762,7 @@ impl Wallet { /// never falls back to `max_balance()` — callers that need certainty /// (e.g., test waiters) should use this and retry on `None`. pub fn spv_confirmed_balance(&self) -> Option { - if self.total_balance > 0 || self.confirmed_balance > 0 || self.unconfirmed_balance > 0 { + if self.spv_balance_known { Some(self.confirmed_balance) } else { None @@ -778,6 +785,7 @@ impl Wallet { self.confirmed_balance = confirmed; self.unconfirmed_balance = unconfirmed; self.total_balance = total; + self.spv_balance_known = true; } pub fn bootstrap_known_addresses(&mut self, app_context: &AppContext) { @@ -2770,6 +2778,7 @@ mod tests { confirmed_balance: 0, unconfirmed_balance: 0, total_balance: 0, + spv_balance_known: false, platform_address_info: BTreeMap::new(), core_wallet_name: None, } @@ -2914,6 +2923,29 @@ mod tests { assert_eq!(wallet.total_balance, 150); } + #[test] + fn test_spv_confirmed_balance_none_before_sync() { + let wallet = test_wallet(); + // Before any SPV sync, spv_confirmed_balance must return None regardless + // of the UTXO state — callers cannot distinguish synced-zero from unsynced. + assert_eq!(wallet.spv_confirmed_balance(), None); + } + + #[test] + fn test_spv_confirmed_balance_zero_after_sync() { + let mut wallet = test_wallet(); + // After SPV reports zero balance, Some(0) must be returned — not None. + wallet.update_spv_balances(0, 0, 0); + assert_eq!(wallet.spv_confirmed_balance(), Some(0)); + } + + #[test] + fn test_spv_confirmed_balance_nonzero_after_sync() { + let mut wallet = test_wallet(); + wallet.update_spv_balances(75_000, 5_000, 80_000); + assert_eq!(wallet.spv_confirmed_balance(), Some(75_000)); + } + // ======================================================================== // select_unspent_utxos_for / remove_selected_utxos tests // ======================================================================== From 3c316a5bb20ffc65cb2edb3e41fa8e6d9006c972 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Mar 2026 18:45:26 +0100 Subject: [PATCH 043/147] =?UTF-8?q?fix(error):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20typed=20BIP32=20source,=20migration=20error=20varia?= =?UTF-8?q?nt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace WalletKeyDerivationFailed { detail: String } with a typed #[source] field (Box) so the error chain is preserved and Display/Debug separation is explicit. Update all callsites in wallet/mod.rs and identity/mod.rs accordingly. Replace the semantically wrong InvalidParameterName map_err in rename_network_dash_to_mainnet with a plain ? — execute() already returns rusqlite::Result so no conversion is needed. Co-Authored-By: Claude Opus 4.6 --- src/backend_task/error.rs | 5 ++++- src/backend_task/identity/mod.rs | 4 ++-- src/database/initialization.rs | 7 +------ src/model/wallet/mod.rs | 6 +++--- 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index 3dc2a3bba..489de1fcc 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -628,7 +628,10 @@ pub enum TaskError { /// Wallet key derivation failed during construction. #[error("Could not create the wallet. Key derivation failed — please try again.")] - WalletKeyDerivationFailed { detail: String }, + WalletKeyDerivationFailed { + #[source] + source: Box, + }, // ────────────────────────────────────────────────────────────────────────── // Shielded pool errors diff --git a/src/backend_task/identity/mod.rs b/src/backend_task/identity/mod.rs index 411179037..3ad5f4277 100644 --- a/src/backend_task/identity/mod.rs +++ b/src/backend_task/identity/mod.rs @@ -489,7 +489,7 @@ pub(crate) fn build_identity_registration( identity_index, 0, ) - .map_err(|e| TaskError::WalletKeyDerivationFailed { detail: e })?; + .map_err(|e| TaskError::WalletKeyDerivationFailed { source: e.into() })?; let mut keys_input: Vec = Vec::new(); for (i, (key_type, purpose, security_level, contract_bounds)) in @@ -503,7 +503,7 @@ pub(crate) fn build_identity_registration( identity_index, key_index, ) - .map_err(|e| TaskError::WalletKeyDerivationFailed { detail: e })?; + .map_err(|e| TaskError::WalletKeyDerivationFailed { source: e.into() })?; keys_input.push(( (private_key, derivation_path), key_type, diff --git a/src/database/initialization.rs b/src/database/initialization.rs index 3744bd793..4301101d4 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -969,12 +969,7 @@ impl Database { conn.execute( &format!("UPDATE {table} SET network = 'mainnet' WHERE network = 'dash'"), [], - ) - .map_err(|e| { - rusqlite::Error::InvalidParameterName(format!( - "migration 29: failed to update network in table `{table}`: {e}" - )) - })?; + )?; } Ok(()) } diff --git a/src/model/wallet/mod.rs b/src/model/wallet/mod.rs index 5db93cb33..f474c0155 100644 --- a/src/model/wallet/mod.rs +++ b/src/model/wallet/mod.rs @@ -402,14 +402,14 @@ impl Wallet { // Derive master BIP44 extended public key let master_priv = ExtendedPrivKey::new_master(network, &seed).map_err(|e| { TaskError::WalletKeyDerivationFailed { - detail: e.to_string(), + source: Box::new(e), } })?; let bip44_path = Self::bip44_account0_path(network); let secp = Secp256k1::new(); let account_priv = master_priv.derive_priv(&secp, &bip44_path).map_err(|e| { TaskError::WalletKeyDerivationFailed { - detail: e.to_string(), + source: Box::new(e), } })?; let master_bip44_ecdsa_extended_public_key = @@ -418,7 +418,7 @@ impl Wallet { // Derive the first receive address (m/44'/coin'/0'/0/0) let (known_addresses, watched_addresses) = Self::derive_first_address(&master_bip44_ecdsa_extended_public_key, network, &secp) - .map_err(|e| TaskError::WalletKeyDerivationFailed { detail: e })?; + .map_err(|e| TaskError::WalletKeyDerivationFailed { source: e.into() })?; Ok(Wallet { wallet_seed: WalletSeed::Open(OpenWalletSeed { From 4d6dd98c4310227b89a1daa5170aaa6a1dd79bb2 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Mar 2026 18:46:54 +0100 Subject: [PATCH 044/147] test(db): add v33 consolidated migration regression tests Two test scenarios verify the v33 consolidated migration that merges sub-migrations v28-v32 (core_wallet_name, shielded tables, contacts, wallet transaction status, network rename): 1. Fresh install creates all v33 tables/columns directly via create_tables() 2. Upgrade from v27 runs the full migration path and produces identical schema Co-Authored-By: Claude Opus 4.6 --- src/database/initialization.rs | 207 ++++++++++++++++++++++++++++++++- 1 file changed, 206 insertions(+), 1 deletion(-) diff --git a/src/database/initialization.rs b/src/database/initialization.rs index 4301101d4..719daff36 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -978,7 +978,70 @@ impl Database { #[cfg(test)] mod test { use crate::database::initialization::DEFAULT_DB_VERSION; - use rusqlite::params; + use rusqlite::{Connection, params}; + + /// Helper: assert that a table exists in the database. + fn assert_table_exists(conn: &Connection, table: &str) { + let exists: bool = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1", + params![table], + |row| row.get::<_, i32>(0).map(|c| c > 0), + ) + .unwrap(); + assert!(exists, "table `{table}` should exist"); + } + + /// Helper: assert that a column exists in a table. + fn assert_column_exists(conn: &Connection, table: &str, column: &str) { + let exists: bool = conn + .query_row( + &format!("SELECT COUNT(*) FROM pragma_table_info('{table}') WHERE name='{column}'"), + [], + |row| row.get::<_, i32>(0).map(|c| c > 0), + ) + .unwrap(); + assert!(exists, "column `{column}` should exist in table `{table}`"); + } + + /// Verify the full v33 schema: all tables and columns introduced in v28-v33. + fn assert_v33_schema(conn: &Connection) { + // wallet.core_wallet_name (v28) + assert_column_exists(conn, "wallet", "core_wallet_name"); + + // shielded_notes table (v29) + assert_table_exists(conn, "shielded_notes"); + for col in [ + "wallet_seed_hash", + "note_data", + "position", + "cmx", + "nullifier", + "block_height", + "is_spent", + "value", + "network", + ] { + assert_column_exists(conn, "shielded_notes", col); + } + + // shielded_wallet_meta table with last_nullifier_sync_timestamp (v30) + assert_table_exists(conn, "shielded_wallet_meta"); + assert_column_exists( + conn, + "shielded_wallet_meta", + "last_nullifier_sync_timestamp", + ); + + // wallet_transactions.status (v30) + assert_column_exists(conn, "wallet_transactions", "status"); + + // contact_private_info table (v29) + assert_table_exists(conn, "contact_private_info"); + + // dashpay_contact_requests table (pre-existing, but checked for completeness) + assert_table_exists(conn, "dashpay_contact_requests"); + } #[test] /// Given a new database file, @@ -1062,4 +1125,146 @@ mod test { "Identity should not be deleted during migration failure" ); } + + #[test] + fn test_v33_migration_fresh_install() { + let temp_dir = tempfile::tempdir().unwrap(); + let db_file_path = temp_dir.path().join("fresh.db"); + let db = super::Database::new(&db_file_path).unwrap(); + db.initialize(&db_file_path).unwrap(); + + let conn = db.conn.lock().unwrap(); + + // Version must be 33 + let version: u16 = conn + .query_row( + "SELECT database_version FROM settings WHERE id = 1", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(version, DEFAULT_DB_VERSION); + assert_eq!(version, 33); + + assert_v33_schema(&conn); + } + + #[test] + fn test_v33_migration_from_v27() { + let temp_dir = tempfile::tempdir().unwrap(); + let db_file_path = temp_dir.path().join("v27.db"); + let db = super::Database::new(&db_file_path).unwrap(); + + // Build a full database then strip v28+ additions to simulate v27. + db.create_tables().unwrap(); + db.set_default_version().unwrap(); + + { + let conn = db.conn.lock().unwrap(); + + // Remove v28+ tables entirely + conn.execute("DROP TABLE IF EXISTS shielded_notes", []) + .unwrap(); + conn.execute("DROP TABLE IF EXISTS shielded_wallet_meta", []) + .unwrap(); + conn.execute("DROP TABLE IF EXISTS contact_private_info", []) + .unwrap(); + + // Recreate `wallet` without `core_wallet_name` (SQLite has no DROP COLUMN) + conn.execute_batch( + "CREATE TABLE wallet_old AS SELECT + seed_hash, encrypted_seed, salt, nonce, + master_ecdsa_bip44_account_0_epk, alias, is_main, + uses_password, password_hint, network, + confirmed_balance, unconfirmed_balance, total_balance, + last_platform_full_sync, last_platform_sync_checkpoint, + last_terminal_block + FROM wallet; + DROP TABLE wallet; + CREATE TABLE wallet ( + seed_hash BLOB NOT NULL PRIMARY KEY, + encrypted_seed BLOB NOT NULL, + salt BLOB NOT NULL, + nonce BLOB NOT NULL, + master_ecdsa_bip44_account_0_epk BLOB NOT NULL, + alias TEXT, + is_main INTEGER, + uses_password INTEGER NOT NULL, + password_hint TEXT, + network TEXT NOT NULL, + confirmed_balance INTEGER DEFAULT 0, + unconfirmed_balance INTEGER DEFAULT 0, + total_balance INTEGER DEFAULT 0, + last_platform_full_sync INTEGER DEFAULT 0, + last_platform_sync_checkpoint INTEGER DEFAULT 0, + last_terminal_block INTEGER DEFAULT 0 + ); + INSERT INTO wallet SELECT * FROM wallet_old; + DROP TABLE wallet_old;", + ) + .unwrap(); + + // Recreate `wallet_transactions` without `status` + conn.execute_batch( + "DROP TABLE IF EXISTS wallet_transactions; + CREATE TABLE wallet_transactions ( + seed_hash BLOB NOT NULL, + txid BLOB NOT NULL, + network TEXT NOT NULL, + timestamp INTEGER NOT NULL, + height INTEGER, + block_hash BLOB, + net_amount INTEGER NOT NULL, + fee INTEGER, + label TEXT, + is_ours INTEGER NOT NULL, + raw_transaction BLOB NOT NULL, + PRIMARY KEY (seed_hash, txid, network) + );", + ) + .unwrap(); + + // Recreate `single_key_wallet` without `core_wallet_name` + conn.execute_batch( + "DROP TABLE IF EXISTS single_key_wallet; + CREATE TABLE single_key_wallet ( + key_hash BLOB NOT NULL PRIMARY KEY, + encrypted_private_key BLOB NOT NULL, + salt BLOB NOT NULL, + nonce BLOB NOT NULL, + public_key BLOB NOT NULL, + address TEXT NOT NULL, + alias TEXT, + uses_password INTEGER NOT NULL, + network TEXT NOT NULL, + confirmed_balance INTEGER DEFAULT 0, + unconfirmed_balance INTEGER DEFAULT 0, + total_balance INTEGER DEFAULT 0 + );", + ) + .unwrap(); + + // Set version to 27 + conn.execute("UPDATE settings SET database_version = 27 WHERE id = 1", []) + .unwrap(); + } + + // Verify version is 27 before migration + assert_eq!(db.db_schema_version().unwrap(), 27); + + // Run migration from v27 to v33 + let result = db.try_perform_migration(27, DEFAULT_DB_VERSION); + assert!( + result.is_ok(), + "migration from v27 to v33 failed: {:?}", + result.err() + ); + + // Verify final version + assert_eq!(db.db_schema_version().unwrap(), 33); + + // Verify full v33 schema + let conn = db.conn.lock().unwrap(); + assert_v33_schema(&conn); + } } From da4814a2c50eb087e551591ce8564ba5a749c967 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Mar 2026 19:18:21 +0100 Subject: [PATCH 045/147] fix(ui): show warning when config save fails instead of success When config.save() fails, stop early with a warning banner instead of continuing to reinit and showing a misleading success message. Co-Authored-By: Claude Opus 4.6 --- src/ui/network_chooser_screen.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index c87c825f3..dd98682f5 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -493,7 +493,12 @@ impl NetworkChooserScreen { ); if let Err(e) = config.save(&self.mainnet_app_context.data_dir) { tracing::error!("Failed to save config to .env: {e}"); - } + MessageBanner::set_global( + ui.ctx(), + "Could not save the configuration file. Your changes will apply for this session only.", + MessageType::Warning, + ); + } else { // Only update the in-memory config and reinit if the // context for this network already exists. If it @@ -545,6 +550,7 @@ impl NetworkChooserScreen { MessageType::Success, ); } + } // else: config.save() succeeded } }); } From 78f25a898afc4cf1fd88a78f0ce5bd0d5ce7f87c Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Mar 2026 21:24:46 +0100 Subject: [PATCH 046/147] fix(error): consolidate format_credits_as_dash and remove jargon from user messages - fee_estimation::format_credits_as_dash now delegates to Amount::dash_from_credits() instead of doing its own f64 math with fixed 8 decimals; output is now trimmed (e.g. "1 DASH" instead of "1.00000000 DASH") - Remove private format_credits_as_dash from error.rs; import the pub version from fee_estimation instead - IdentityInsufficientBalance: show amounts in DASH rather than raw credit integers - ShieldedAnchorMismatch: replace "out of sync" with plain-language retry guidance - ShieldedFeeExceedsBalance: remove "shielded balance" / "shield more credits" jargon - ShieldedInsufficientPoolNotes: remove count interpolations and ZK pool jargon - Update tests throughout to match new message content and format Co-Authored-By: Claude Sonnet 4.6 --- src/backend_task/error.rs | 42 ++++++++++++++++++------------------- src/model/fee_estimation.rs | 12 +++++------ 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index 489de1fcc..f3b12da7d 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -3,6 +3,7 @@ //! `Display` → user-friendly text (shown in `MessageBanner`). //! `Debug` → variant name + fields (logged and shown in collapsible details). +use crate::model::fee_estimation::format_credits_as_dash; use dash_sdk::Error as SdkError; use dash_sdk::dashcore_rpc; use dash_sdk::dpp::ProtocolError; @@ -319,8 +320,10 @@ pub enum TaskError { /// The identity doesn't have enough Platform credits for this operation. #[error( - "Not enough Platform credits. Your identity has {available} credits \ - but this operation requires {required}. Please top up your identity first." + "Not enough balance. You have {available_dash} but this operation requires {required_dash}. \ + Please top up your identity first.", + available_dash = format_credits_as_dash(*.available), + required_dash = format_credits_as_dash(*.required) )] IdentityInsufficientBalance { available: u64, @@ -659,12 +662,12 @@ pub enum TaskError { ShieldedTransitionBuildFailed { detail: String }, /// The shielded note witnesses are stale — the commitment tree changed since sync. - #[error("Your shielded notes are out of sync. Please sync your shielded wallet and retry.")] + #[error("Your wallet data is slightly outdated. Please wait a moment and try again.")] ShieldedAnchorMismatch { detail: String }, /// The amount plus network fee exceeds the spendable shielded balance. #[error( - "The amount plus the network fee ({fee_dash}) exceeds your shielded balance. Reduce the amount or shield more credits.", + "The amount plus the network fee ({fee_dash}) exceeds your available balance. Reduce the amount or add more funds.", fee_dash = format_credits_as_dash(*.fee) )] ShieldedFeeExceedsBalance { @@ -684,7 +687,7 @@ pub enum TaskError { /// The shielded pool does not have enough notes for an outgoing transaction. #[error( - "The shielded pool needs more participants before you can unshield. The pool has {current_count} notes but requires at least {minimum_required}. Please try again later as more users join the pool." + "This type of transaction is not available right now because the network needs more activity. Please try again later." )] ShieldedInsufficientPoolNotes { current_count: u64, @@ -764,11 +767,6 @@ pub fn is_instant_lock_proof_invalid(error: &SdkError) -> bool { ) } -/// Format a credit amount as Dash using `Amount`'s Display implementation. -fn format_credits_as_dash(credits: u64) -> String { - crate::model::amount::Amount::dash_from_credits(credits).to_string() -} - // TODO: Replace string parsing with a pre-check on amount + fee > spendable // before calling the SDK builder, or wait for upstream to add a typed // ProtocolError variant (currently ProtocolError::ShieldedBuildError(String)). @@ -1372,12 +1370,8 @@ mod tests { let err = TaskError::from(sdk_err); let msg = err.to_string(); assert!( - msg.contains("12656420"), - "Expected available balance in message, got: {msg}" - ); - assert!( - msg.contains("42332820"), - "Expected required balance in message, got: {msg}" + msg.contains("DASH"), + "Expected DASH amounts in message, got: {msg}" ); assert!( msg.contains("top up"), @@ -1528,18 +1522,20 @@ mod tests { } #[test] - fn insufficient_pool_notes_display_includes_counts() { + fn insufficient_pool_notes_display_is_user_friendly() { use dash_sdk::dpp::consensus::state::shielded::insufficient_pool_notes_error::InsufficientPoolNotesError; let consensus = ConsensusError::from(InsufficientPoolNotesError::new(14, 250)); let sdk_err = SdkError::from(consensus); let err = TaskError::from(sdk_err); let msg = err.to_string(); - assert!(msg.contains("14"), "Expected current count, got: {msg}"); - assert!(msg.contains("250"), "Expected minimum required, got: {msg}"); assert!( msg.contains("try again later"), "Expected actionable guidance, got: {msg}" ); + assert!( + !msg.contains("14") && !msg.contains("250"), + "Expected no technical counts in user message, got: {msg}" + ); } #[test] @@ -1594,8 +1590,12 @@ mod tests { }; let msg = err.to_string(); assert!( - msg.contains("out of sync"), - "Expected sync message, got: {msg}" + msg.contains("try again"), + "Expected actionable guidance, got: {msg}" + ); + assert!( + !msg.contains("sync") && !msg.contains("anchor"), + "Expected no ZK jargon in user message, got: {msg}" ); } } diff --git a/src/model/fee_estimation.rs b/src/model/fee_estimation.rs index 07bf082af..bdd96fcd5 100644 --- a/src/model/fee_estimation.rs +++ b/src/model/fee_estimation.rs @@ -12,6 +12,7 @@ //! performed by Platform. For accurate fees, use Platform's EstimateStateTransitionFee //! endpoint (when available). +use crate::model::amount::Amount; use dash_sdk::dpp::version::PlatformVersion; /// Storage fee constants from FEE_STORAGE_VERSION1 in rs-platform-version. @@ -637,8 +638,7 @@ pub const CREDITS_PER_DASH: u64 = 100_000_000_000; /// Format credits as DASH for display pub fn format_credits_as_dash(credits: u64) -> String { - let dash = credits as f64 / CREDITS_PER_DASH as f64; - format!("{:.8} DASH", dash) + Amount::dash_from_credits(credits).to_string() } /// Format credits for display (with both credits and DASH) @@ -684,7 +684,7 @@ mod tests { let fee = estimator.calculate_storage_fee(500); assert_eq!(fee, 500 * 27_000); // 13,500,000 credits = 0.000135 DASH (at 100 billion credits per DASH) - assert_eq!(format_credits_as_dash(fee), "0.00013500 DASH"); + assert_eq!(format_credits_as_dash(fee), "0.000135 DASH"); } #[test] @@ -730,8 +730,8 @@ mod tests { #[test] fn test_format_credits() { // 1 DASH = 100,000,000,000 credits - assert_eq!(format_credits_as_dash(100_000_000_000), "1.00000000 DASH"); - assert_eq!(format_credits_as_dash(100_000_000), "0.00100000 DASH"); - assert_eq!(format_credits_as_dash(100_000), "0.00000100 DASH"); + assert_eq!(format_credits_as_dash(100_000_000_000), "1 DASH"); + assert_eq!(format_credits_as_dash(100_000_000), "0.001 DASH"); + assert_eq!(format_credits_as_dash(100_000), "0.000001 DASH"); } } From 89d8a972160a5bfdd7bb2c9d21e9e0ae21ef3067 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:09:10 +0100 Subject: [PATCH 047/147] fix(test): remove duplicate wallet store in register_test_address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit register_test_address called db.store_wallet redundantly — callers already store the wallet before calling it, causing UNIQUE constraint violations when tests run in parallel on CI. Co-Authored-By: Claude Opus 4.6 --- src/model/wallet/mod.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/model/wallet/mod.rs b/src/model/wallet/mod.rs index c0fe02425..7edc4aa42 100644 --- a/src/model/wallet/mod.rs +++ b/src/model/wallet/mod.rs @@ -3004,9 +3004,8 @@ mod tests { /// Helper: register a wallet address in the test database so that /// `update_address_balance` can find the row. + /// Caller must store the wallet first via `db.store_wallet()`. fn register_test_address(db: &Database, wallet: &Wallet, address: &Address) { - db.store_wallet(wallet, &Network::Testnet) - .expect("store test wallet"); let seed_hash = wallet.seed_hash(); let path = DerivationPath::from(vec![ ChildNumber::Hardened { index: 44 }, From aff86d97e59caef1b3c00a46238494113a0c5483 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 09:18:29 +0100 Subject: [PATCH 048/147] fix(core): log chain lock RPC errors that aren't auth/connection failures Silent swallowing of errors like "Unable to find any ChainLock" made it impossible to diagnose why the active network showed as Disconnected. Now warns via tracing when chain_lock_rpc_error returns None. Co-Authored-By: Claude Sonnet 4.6 --- src/backend_task/core/mod.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/backend_task/core/mod.rs b/src/backend_task/core/mod.rs index fe3153e89..7ef51faa8 100644 --- a/src/backend_task/core/mod.rs +++ b/src/backend_task/core/mod.rs @@ -192,10 +192,12 @@ impl AppContext { Network::Regtest => (&local_result, maybe_local_config), _ => (&mainnet_result, maybe_mainnet_config), }; - if let Err(e) = active_result - && let Some(task_err) = Self::chain_lock_rpc_error(active_config, e) - { - return Err(task_err); + if let Err(e) = active_result { + if let Some(task_err) = Self::chain_lock_rpc_error(active_config, e) { + return Err(task_err); + } else { + tracing::warn!(network = ?self.network, error = %e, "Chain lock query failed on active network"); + } } // Convert each to Option (flatten Ok(None) and Err into None) From 1a8c047190a334fe26c53abe8421d7dbe6fb62b7 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 09:23:27 +0100 Subject: [PATCH 049/147] feat(ui): surface chain lock RPC errors in Networks tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, non-auth/non-connection RPC errors (e.g. "Unable to find any ChainLock") were silently swallowed — the UI showed "Disconnected" with no explanation. Now the error message is carried through the ChainLocks result, stored in ConnectionStatus.rpc_last_error, and displayed as "Error: ..." in both developer and non-developer RPC status labels on the Networks tab. Co-Authored-By: Claude Opus 4.6 --- src/backend_task/core/mod.rs | 14 +++++++++----- src/context/connection_status.rs | 24 ++++++++++++++++++++++++ src/ui/network_chooser_screen.rs | 21 +++++++++++++++++---- 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/src/backend_task/core/mod.rs b/src/backend_task/core/mod.rs index 7ef51faa8..afcb3b786 100644 --- a/src/backend_task/core/mod.rs +++ b/src/backend_task/core/mod.rs @@ -138,7 +138,8 @@ pub enum CoreItem { Option, Option, Option, - ), // Mainnet, Testnet, Devnet, Local + Option, + ), // Mainnet, Testnet, Devnet, Local, active network RPC error ChainLockedBlock(Block, ChainLock), } @@ -192,13 +193,15 @@ impl AppContext { Network::Regtest => (&local_result, maybe_local_config), _ => (&mainnet_result, maybe_mainnet_config), }; - if let Err(e) = active_result { + let active_rpc_error = if let Err(e) = active_result { if let Some(task_err) = Self::chain_lock_rpc_error(active_config, e) { return Err(task_err); - } else { - tracing::warn!(network = ?self.network, error = %e, "Chain lock query failed on active network"); } - } + tracing::warn!(network = ?self.network, error = %e, "Chain lock query failed on active network"); + Some(e.to_string()) + } else { + None + }; // Convert each to Option (flatten Ok(None) and Err into None) let mainnet_chainlock = mainnet_result.ok().flatten(); @@ -212,6 +215,7 @@ impl AppContext { testnet_chainlock, devnet_chainlock, local_chainlock, + active_rpc_error, ))) } CoreTask::RefreshWalletInfo(wallet, sync_platform) => { diff --git a/src/context/connection_status.rs b/src/context/connection_status.rs index 7ff068362..c242918bb 100644 --- a/src/context/connection_status.rs +++ b/src/context/connection_status.rs @@ -59,6 +59,7 @@ pub struct ConnectionStatus { // NOTE: Mutex (not RwLock) is intentional — single reader (tooltip hover), // single writer (poll cycle), minimal contention. RwLock overhead not justified. spv_last_error: Mutex>, + rpc_last_error: Mutex>, last_update: Mutex, spv_connected_peers: AtomicU16, /// When SPV first entered an active state (`Starting`/`Syncing`) with zero @@ -78,6 +79,7 @@ impl ConnectionStatus { disable_zmq: AtomicBool::new(false), overall_state: AtomicU8::new(OverallConnectionState::Disconnected as u8), spv_last_error: Mutex::new(None), + rpc_last_error: Mutex::new(None), last_update: Mutex::new(Instant::now()), spv_connected_peers: AtomicU16::new(0), spv_no_peers_since: Mutex::new(None), @@ -113,6 +115,9 @@ impl ConnectionStatus { if let Ok(mut err) = self.spv_last_error.lock() { *err = None; } + if let Ok(mut err) = self.rpc_last_error.lock() { + *err = None; + } // Set last_update to epoch so the next trigger_refresh fires immediately *self.last_update.lock().unwrap_or_else(|e| e.into_inner()) = Instant::now() - REFRESH_CONNECTED; @@ -124,6 +129,23 @@ impl ConnectionStatus { pub fn set_rpc_online(&self, online: bool) { self.rpc_online.store(online, Ordering::Relaxed); + if online { + self.set_rpc_last_error(None); + } + } + + /// Set the last RPC error message (from chain lock polling). + pub fn set_rpc_last_error(&self, error: Option) { + let mut err = self + .rpc_last_error + .lock() + .unwrap_or_else(|e| e.into_inner()); + *err = error; + } + + /// Get the last RPC error message, if any. + pub fn rpc_last_error(&self) -> Option { + self.rpc_last_error.lock().ok().and_then(|g| g.clone()) } pub fn zmq_connected(&self) -> bool { @@ -414,6 +436,7 @@ impl ConnectionStatus { testnet_chainlock, devnet_chainlock, local_chainlock, + rpc_error, )) => { self.update_from_chainlocks( active_network, @@ -422,6 +445,7 @@ impl ConnectionStatus { devnet_chainlock, local_chainlock, ); + self.set_rpc_last_error(rpc_error.clone()); self.refresh_state(); } BackendTaskSuccessResult::CoreItem(CoreItem::ChainLock(_, network)) => { diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index dd98682f5..6dd7b52c1 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -575,6 +575,7 @@ impl NetworkChooserScreen { let zmq_connected = status.zmq_connected(); let spv_status = status.spv_status(); let spv_connected = ConnectionStatus::spv_connected(spv_status); + let rpc_last_error = status.rpc_last_error(); let spv_error_detail = status.spv_last_error(); let snapshot = if current_backend_mode == CoreBackendMode::Spv { Some(ctx.spv_manager().status().clone()) @@ -722,8 +723,14 @@ impl NetworkChooserScreen { } else { DashColors::ERROR }; - let rpc_label = if rpc_online { "Connected" } else { "Disconnected" }; - ui.colored_label(rpc_color, rpc_label); + let rpc_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(rpc_color, &rpc_label); ui.label(","); ui.label("ZMQ:"); @@ -752,8 +759,14 @@ impl NetworkChooserScreen { } else { DashColors::ERROR }; - let label = if rpc_online { "Connected" } else { "Disconnected" }; - ui.colored_label(color, label); + 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| { From ddcb74220538f9129aae978817c50d00506e9eb0 Mon Sep 17 00:00:00 2001 From: lklimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 11:03:37 +0100 Subject: [PATCH 050/147] feat(ui): unified AddressInput component with autocomplete (#787) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): add unified AddressInput component with autocomplete Introduce a reusable AddressInput component that handles text input with real-time address type detection, autocomplete from wallet data, balance display, type filtering, and network-aware validation. Supports Core, Platform, Shielded, and Identity address kinds. New files: - src/model/address.rs: AddressKind enum and ValidatedAddress enum - src/ui/components/address_input.rs: full component with 33 unit tests Co-Authored-By: Claude Opus 4.6 (1M context) * feat(ui): integrate AddressInput component into send and unshield screens Replace inline address parsing and validation with the unified AddressInput component across 3 proof-of-concept sites: - UnshieldCreditsScreen: full migration, removes local Destination enum and parse_destination(), gains autocomplete and type-restricted input - WalletSendScreen simple mode: full migration, removes AddressType enum, replaces detect_address_type/is_shielded_address with AddressKind-based detection, eliminates double-parsing in send handlers - WalletSendScreen advanced mode: minimal migration, updates type detection to use AddressKind, keeps CoreAddressInput/PlatformAddressInput structs unchanged Net reduction of ~47 lines per screen. All send flows preserved. Co-Authored-By: Claude Opus 4.6 (1M context) * test(address-input): fix misleading test and add missing coverage The existing `disabled_type_rejected_with_correct_error` test was testing empty input (which returns no error) rather than actually verifying type restriction. Fixed the test and added coverage for: - Selection-only mode rejection of manual input - Identity validation with valid/invalid identifiers - Truncate boundary at exactly 16/17 characters - Empty input in selection-only and restricted-type modes Co-Authored-By: Claude Opus 4.6 (1M context) * fix(ui): address QA findings for AddressInput component QA-001: Extract duplicated address detection logic from send_screen.rs into AddressKind::detect() on the model type. Both send_screen and address_input now delegate to the single canonical implementation. QA-002: Fix autocomplete "...and N more" count using unfiltered total. filtered_entries() now returns the pre-truncation match count so the overflow label shows the correct number of remaining matches. QA-003: Add minimum length check (>= 60 chars) to shielded address validation. Previously any string with the correct prefix was accepted. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(ui): address bot review findings for AddressInput component - Validate shielded addresses via OrchardAddress::from_bech32m_string() instead of prefix+length check only - Use char-aware truncation in truncate_address() to prevent panics on multi-byte UTF-8 input (DPNS labels, emoji) - Reset AddressInput when source selection changes in send screen, and configure allowed destination kinds per source type - Store bech32m string in ValidatedAddress::Platform variant so to_address_string() returns canonical encoding instead of debug hex Co-Authored-By: Claude Opus 4.6 (1M context) * fix(ui): address remaining review findings for AddressInput component - Fix manual entry not propagating validated address on blur (HIGH) - Fix selected_from_autocomplete causing repeated change signals - Remove protocol jargon from unshield screen messages - Reject mixed-case bech32m platform addresses per BIP-350 - Use per-instance ComboBox ID to prevent state collision - Clear cached_detection after autocomplete selection - Fix doc comments and reuse filtered_entries() computation Co-Authored-By: Claude Opus 4.6 (1M context) * feat(ui): support multi-wallet autocomplete in AddressInput Replace with_wallet/set_wallet (single wallet) with with_wallets/set_wallets (slice of wallets). When multiple wallets are loaded, each autocomplete entry is prefixed with the wallet alias so the user can tell which wallet owns the address. send_screen.rs now passes all loaded wallets to AddressInput instead of only the selected one. Co-Authored-By: Claude Sonnet 4.6 * fix(ui): restore missing "Show zero-balance addresses" checkbox The checkbox was accidentally dropped during a branch merge. Restored the horizontal layout with heading on the left and checkbox right-aligned, matching the v1.0-dev implementation. Co-Authored-By: Claude Opus 4.6 * fix(ui): improve AddressInput autocomplete UX - Fix click selection: keep popup rendered when text field loses focus so the click handler fires (was gated on has_focus only) - Show dropdown on focus with any input length (removed 3-char minimum) - Add type suffix (Core), (Platform), (Identity), (Shielded) to dropdown entries when multiple address types are enabled - Validate immediately on paste (text >3 chars) instead of requiring blur first, so "Fund Platform Address" button activates without needing to click away Co-Authored-By: Claude Opus 4.6 * fix(ui): make entire autocomplete row clickable in AddressInput Previously only the address label was clickable — clicking the balance text on the right side of the row did nothing. Now the click handler uses the horizontal row response, so the entire row triggers selection. Co-Authored-By: Claude Opus 4.6 * fix(ui): fix autocomplete click handling and format balances with 4dp - Capture selectable_label click response instead of discarding it; combine with row-level click for full-row clickability - Format all DASH balances in dropdown with exactly 4 decimal places Co-Authored-By: Claude Opus 4.6 * fix(ui): use single interaction rect for full-row clickable autocomplete Replace selectable_label + horizontal layout with allocate_exact_size and manual painting — no child widgets steal clicks, the entire row (address, balance, dead space) is clickable with hover feedback. Co-Authored-By: Claude Opus 4.6 * feat(ui): add wallet address autocomplete to unshield screen Populate AddressInput with wallet addresses via .with_wallets() so users can select a destination from the dropdown instead of manually entering addresses. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 (1M context) --- src/model/address.rs | 318 +++++ src/model/mod.rs | 1 + src/ui/components/address_input.rs | 1524 +++++++++++++++++++++ src/ui/components/mod.rs | 1 + src/ui/wallets/send_screen.rs | 378 +++-- src/ui/wallets/unshield_credits_screen.rs | 105 +- src/ui/wallets/wallets_screen/mod.rs | 19 +- 7 files changed, 2081 insertions(+), 265 deletions(-) create mode 100644 src/model/address.rs create mode 100644 src/ui/components/address_input.rs diff --git a/src/model/address.rs b/src/model/address.rs new file mode 100644 index 000000000..5a768ab18 --- /dev/null +++ b/src/model/address.rs @@ -0,0 +1,318 @@ +use dash_sdk::dashcore_rpc::dashcore::address::NetworkUnchecked; +use dash_sdk::dashcore_rpc::dashcore::{Address, Network}; +use dash_sdk::dpp::address_funds::PlatformAddress; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use dash_sdk::platform::Identifier; + +/// Classification of a Dash address for filtering and display purposes. +/// +/// This enum represents the four recognized address categories. It is used +/// by `AddressInput` to configure which address types are accepted and to +/// label entries in the autocomplete dropdown. +/// +/// Unlike the internal detection concept, there is no `Unknown` variant here -- +/// an address either falls into one of these categories or fails validation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum AddressKind { + /// Core L1 address (P2PKH / P2SH, Base58Check). + Core, + /// Platform L2 address (Bech32m per DIP-18). + Platform, + /// Shielded Orchard address (dash1z... / tdash1z...). + Shielded, + /// Identity identifier (Base58-encoded Identifier). + Identity, +} + +impl AddressKind { + /// User-facing display name, suitable for i18n extraction. + pub fn display_name(&self) -> &'static str { + match self { + Self::Core => "Wallet address", + Self::Platform => "Platform address", + Self::Shielded => "Private address", + Self::Identity => "Identity", + } + } + + /// Short label for use in parenthetical suffixes, e.g. "(Core)". + pub fn short_label(&self) -> &'static str { + match self { + Self::Core => "Core", + Self::Platform => "Platform", + Self::Shielded => "Shielded", + Self::Identity => "Identity", + } + } + + /// All supported address kinds. + pub const ALL: [AddressKind; 4] = [ + AddressKind::Core, + AddressKind::Platform, + AddressKind::Shielded, + AddressKind::Identity, + ]; + + /// Detect the address kind from a raw input string. + /// + /// Priority: Shielded > Platform > Core > Identity (Base58 fallback). + /// Returns `None` for empty or unrecognized input. + pub fn detect(input: &str, _network: Network) -> Option { + let trimmed = input.trim(); + if trimmed.is_empty() { + return None; + } + + // 1. Shielded (dash1z... / tdash1z...) + if trimmed.starts_with("dash1z") || trimmed.starts_with("tdash1z") { + return Some(AddressKind::Shielded); + } + + // 2. Platform (Bech32m per DIP-18, but NOT shielded — already excluded above) + if crate::ui::helpers::is_platform_address_string(trimmed) { + return Some(AddressKind::Platform); + } + + // 3. Core (Base58Check) + if trimmed.parse::>().is_ok() { + return Some(AddressKind::Core); + } + + // 4. Identity (Base58 fallback) + if Identifier::from_string(trimmed, Encoding::Base58).is_ok() { + return Some(AddressKind::Identity); + } + + None + } +} + +impl std::fmt::Display for AddressKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.display_name()) + } +} + +/// A fully validated address with its parsed typed payload. +/// +/// This is the domain type produced by `AddressInput` via `ComponentResponse`. +/// Each variant carries the parsed representation for its address type. +#[derive(Debug, Clone)] +pub enum ValidatedAddress { + /// A validated Core L1 address. + Core(Address), + /// A validated Platform L2 address with its canonical bech32m encoding. + Platform { + address: PlatformAddress, + bech32m: String, + }, + /// A validated shielded Orchard address (stored as the raw string). + Shielded(String), + /// A validated identity identifier with optional DPNS name. + Identity { + /// The parsed identity identifier. + id: Identifier, + /// Resolved DPNS name, if available from local data. + dpns_name: Option, + }, +} + +impl ValidatedAddress { + /// Returns the `AddressKind` for this validated address. + pub fn kind(&self) -> AddressKind { + match self { + Self::Core(_) => AddressKind::Core, + Self::Platform { .. } => AddressKind::Platform, + Self::Shielded(_) => AddressKind::Shielded, + Self::Identity { .. } => AddressKind::Identity, + } + } + + /// Returns the raw address string representation. + pub fn to_address_string(&self) -> String { + match self { + Self::Core(addr) => addr.to_string(), + Self::Platform { bech32m, .. } => bech32m.clone(), + Self::Shielded(s) => s.clone(), + Self::Identity { id, .. } => { + id.to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58) + } + } + } + + /// Returns the core address if this is a Core variant. + pub fn as_core(&self) -> Option<&Address> { + match self { + Self::Core(addr) => Some(addr), + _ => None, + } + } + + /// Returns the platform address if this is a Platform variant. + pub fn as_platform(&self) -> Option<&PlatformAddress> { + match self { + Self::Platform { address, .. } => Some(address), + _ => None, + } + } + + /// Returns the identity ID if this is an Identity variant. + pub fn as_identity_id(&self) -> Option<&Identifier> { + match self { + Self::Identity { id, .. } => Some(id), + _ => None, + } + } + + /// Returns the DPNS name if this is an Identity variant with a resolved name. + pub fn dpns_name(&self) -> Option<&str> { + match self { + Self::Identity { dpns_name, .. } => dpns_name.as_deref(), + _ => None, + } + } +} + +impl std::fmt::Display for ValidatedAddress { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Core(addr) => write!(f, "{}", addr), + Self::Platform { bech32m, .. } => write!(f, "{}", bech32m), + Self::Shielded(s) => write!(f, "{}", s), + Self::Identity { + id, + dpns_name: Some(name), + } => write!(f, "{} ({})", name, id), + Self::Identity { + id, + dpns_name: None, + } => write!( + f, + "{}", + id.to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58) + ), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn address_kind_display_names() { + assert_eq!(AddressKind::Core.display_name(), "Wallet address"); + assert_eq!(AddressKind::Platform.display_name(), "Platform address"); + assert_eq!(AddressKind::Shielded.display_name(), "Private address"); + assert_eq!(AddressKind::Identity.display_name(), "Identity"); + } + + #[test] + fn address_kind_all_contains_four_variants() { + assert_eq!(AddressKind::ALL.len(), 4); + } + + #[test] + fn validated_address_kind_round_trips() { + let shielded = ValidatedAddress::Shielded("dash1z_test".to_string()); + assert_eq!(shielded.kind(), AddressKind::Shielded); + assert_eq!(shielded.to_address_string(), "dash1z_test"); + } + + #[test] + fn validated_address_accessors_return_none_for_wrong_variant() { + let shielded = ValidatedAddress::Shielded("dash1z_test".to_string()); + assert!(shielded.as_core().is_none()); + assert!(shielded.as_platform().is_none()); + assert!(shielded.as_identity_id().is_none()); + assert!(shielded.dpns_name().is_none()); + } + + // --- AddressKind::detect tests --- + + #[test] + fn detect_empty_returns_none() { + assert_eq!(AddressKind::detect("", Network::Testnet), None); + assert_eq!(AddressKind::detect(" ", Network::Testnet), None); + } + + #[test] + fn detect_shielded_mainnet() { + assert_eq!( + AddressKind::detect("dash1z_some_shielded_addr", Network::Mainnet), + Some(AddressKind::Shielded) + ); + } + + #[test] + fn detect_shielded_testnet() { + assert_eq!( + AddressKind::detect("tdash1z_some_shielded_addr", Network::Testnet), + Some(AddressKind::Shielded) + ); + } + + #[test] + fn detect_shielded_priority_over_platform() { + // dash1z starts with "dash1" which could match platform, but shielded wins + assert_eq!( + AddressKind::detect("dash1z_test", Network::Mainnet), + Some(AddressKind::Shielded) + ); + } + + #[test] + fn detect_platform_testnet() { + assert_eq!( + AddressKind::detect("tdash1qwer1234", Network::Testnet), + Some(AddressKind::Platform) + ); + } + + #[test] + fn detect_platform_mainnet() { + assert_eq!( + AddressKind::detect("dash1qwer1234", Network::Mainnet), + Some(AddressKind::Platform) + ); + } + + #[test] + fn detect_core_address() { + use dash_sdk::dashcore_rpc::dashcore::secp256k1::{Secp256k1, SecretKey}; + use dash_sdk::dashcore_rpc::dashcore::{PrivateKey, PublicKey}; + + let secp = Secp256k1::new(); + let sk = SecretKey::from_slice(&[1u8; 32]).unwrap(); + let privkey = PrivateKey::new(sk, Network::Testnet); + let pubkey = PublicKey::from_private_key(&secp, &privkey); + let addr = Address::p2pkh(&pubkey, Network::Testnet); + assert_eq!( + AddressKind::detect(&addr.to_string(), Network::Testnet), + Some(AddressKind::Core) + ); + } + + #[test] + fn detect_identity_base58_fallback() { + let id = Identifier::random(); + let id_str = id.to_string(Encoding::Base58); + // Some random identifiers parse as Core addresses. Skip those for + // this test — only assert identity detection for ones that do not. + if AddressKind::detect(&id_str, Network::Testnet) == Some(AddressKind::Core) { + return; + } + assert_eq!( + AddressKind::detect(&id_str, Network::Testnet), + Some(AddressKind::Identity) + ); + } + + #[test] + fn detect_garbage_returns_none() { + assert_eq!( + AddressKind::detect("not-an-address", Network::Testnet), + None + ); + } +} diff --git a/src/model/mod.rs b/src/model/mod.rs index d92cf061f..3c4f7a09d 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -1,3 +1,4 @@ +pub mod address; pub mod amount; pub mod contested_name; pub mod fee_estimation; diff --git a/src/ui/components/address_input.rs b/src/ui/components/address_input.rs new file mode 100644 index 000000000..b1ca648b6 --- /dev/null +++ b/src/ui/components/address_input.rs @@ -0,0 +1,1524 @@ +use crate::model::address::{AddressKind, ValidatedAddress}; +use crate::model::amount::{Amount, DASH_DECIMAL_PLACES}; +use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::wallet::Wallet; +use crate::ui::components::{Component, ComponentResponse}; +use crate::ui::theme::DashColors; +use dash_sdk::dashcore_rpc::dashcore::address::NetworkUnchecked; +use dash_sdk::dashcore_rpc::dashcore::{Address, Network}; +use dash_sdk::dpp::address_funds::PlatformAddress; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use dash_sdk::platform::Identifier; +use egui::{InnerResponse, Response, Ui, WidgetText}; +use std::ops::Bound; +use std::sync::{Arc, RwLock}; + +/// Internal detection result including the `Unknown` state for unrecognized input. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DetectedType { + Core, + Platform, + Shielded, + Identity, + Unknown, +} + +impl DetectedType { + fn to_address_kind(self) -> Option { + match self { + Self::Core => Some(AddressKind::Core), + Self::Platform => Some(AddressKind::Platform), + Self::Shielded => Some(AddressKind::Shielded), + Self::Identity => Some(AddressKind::Identity), + Self::Unknown => None, + } + } +} + +/// A single autocomplete entry rendered in the dropdown. +/// +/// Pre-computed from wallet/identity data at builder/setter time. +#[derive(Debug, Clone)] +struct AddressEntry { + /// The full address string (populates the text field on selection). + address_string: String, + /// Classification of this entry. + address_kind: AddressKind, + /// Human-readable label (DPNS name, alias, or truncated address). + display_label: String, + /// Balance in native units (duffs for Core, credits for Platform/Shielded/Identity). + balance: u64, + /// Pre-built ValidatedAddress for immediate use on selection. + validated: ValidatedAddress, +} + +/// Concrete balance range bounds. +/// +/// `RangeBounds` is not object-safe, so we extract start/end bounds +/// at configuration time and store them concretely. +#[derive(Debug, Clone)] +struct BalanceRange { + start: Bound, + end: Bound, +} + +impl BalanceRange { + fn from_range(range: &impl std::ops::RangeBounds) -> Self { + Self { + start: range.start_bound().cloned(), + end: range.end_bound().cloned(), + } + } + + fn contains(&self, value: u64) -> bool { + let start_ok = match self.start { + Bound::Included(s) => value >= s, + Bound::Excluded(s) => value > s, + Bound::Unbounded => true, + }; + let end_ok = match self.end { + Bound::Included(e) => value <= e, + Bound::Excluded(e) => value < e, + Bound::Unbounded => true, + }; + start_ok && end_ok + } +} + +/// Response from the `AddressInput` component. +#[derive(Clone)] +pub struct AddressInputResponse { + /// The egui response from the primary text input widget. + pub response: Response, + /// Whether the component's value changed this frame. + changed: bool, + /// Validation error message, if any. + error_message: Option, + /// The validated address, if input is valid. + validated_address: Option, +} + +impl ComponentResponse for AddressInputResponse { + type DomainType = ValidatedAddress; + + fn has_changed(&self) -> bool { + self.changed + } + + fn is_valid(&self) -> bool { + self.error_message.is_none() + } + + fn changed_value(&self) -> &Option { + &self.validated_address + } + + fn error_message(&self) -> Option<&str> { + self.error_message.as_deref() + } +} + +/// Unified address input with autocomplete, type detection, and validation. +/// +/// Follows the Component design pattern: lazy-initialize as `Option` +/// in screen structs, configure via builder methods, render with `show()`, +/// bind to domain data with `response.inner.update(&mut self.address)`. +/// +/// # Usage +/// +/// ```rust,ignore +/// let addr_input = self.address_input.get_or_insert_with(|| { +/// AddressInput::new(network) +/// .with_wallets(&wallets) +/// .with_label("Destination address") +/// .with_hint_text("Enter address or username") +/// }); +/// +/// let response = addr_input.show(ui); +/// response.inner.update(&mut self.validated_address); +/// ``` +pub struct AddressInput { + // --- Configuration --- + network: Network, + enabled_kinds: Vec, + show_type_filter: bool, + dpns_resolution: bool, + developer_mode: bool, + selection_only: bool, + full_addresses: bool, + label: Option, + hint_text: Option, + desired_width: Option, + show_validation_errors: bool, + balance_range: Option, + + // --- Autocomplete data (set via builder, read each frame) --- + all_entries: Vec, + + // --- Mutable UI state --- + input_text: String, + selected_type_filter: Option, + autocomplete_highlight: Option, + autocomplete_open: bool, + has_blurred: bool, + selected_from_autocomplete: bool, + cached_detection: Option<(String, DetectedType)>, + changed: bool, +} + +impl AddressInput { + /// Create a new `AddressInput` for the given network. + /// + /// Default: all four address kinds enabled, no wallet data, no autocomplete. + pub fn new(network: Network) -> Self { + Self { + network, + enabled_kinds: AddressKind::ALL.to_vec(), + show_type_filter: false, + dpns_resolution: true, + developer_mode: false, + selection_only: false, + full_addresses: false, + label: None, + hint_text: None, + desired_width: None, + show_validation_errors: true, + balance_range: None, + all_entries: Vec::new(), + input_text: String::new(), + selected_type_filter: None, + autocomplete_highlight: None, + autocomplete_open: false, + has_blurred: false, + selected_from_autocomplete: false, + cached_detection: None, + changed: false, + } + } + + /// Restrict which address kinds are accepted and shown. + pub fn with_address_kinds(mut self, kinds: &[AddressKind]) -> Self { + self.enabled_kinds = kinds.to_vec(); + self + } + + /// Provide wallet data for Core and Platform autocomplete. + /// + /// Entries are extracted immediately (read lock acquired once per wallet). + /// Skips gracefully if a wallet lock is poisoned. + /// When more than one wallet is provided, entries are prefixed with the wallet alias. + pub fn with_wallets(mut self, wallets: &[Arc>]) -> Self { + let multi = wallets.len() > 1; + for wallet in wallets { + self.extract_wallet_entries(wallet, multi); + } + self + } + + /// Provide identity references for Identity-type autocomplete. + pub fn with_identities(mut self, identities: &[QualifiedIdentity]) -> Self { + self.extract_identity_entries(identities); + self + } + + /// Provide shielded address and balance for Shielded-type autocomplete. + pub fn with_shielded_balance(mut self, address: String, balance: u64) -> Self { + self.add_shielded_entry(address, balance); + self + } + + /// Show a type filter dropdown to the left of the text input. + /// + /// Only displayed when more than one address kind is enabled. Default: false. + pub fn with_type_filter_dropdown(mut self, show: bool) -> Self { + self.show_type_filter = show; + self + } + + /// Filter autocomplete entries by balance range (in native units). + /// + /// Does not affect manual input validation. Default: no filter. + pub fn with_balance_range(mut self, range: impl std::ops::RangeBounds) -> Self { + self.balance_range = Some(BalanceRange::from_range(&range)); + self + } + + /// Enable DPNS username resolution for Identity-type addresses. Default: true. + pub fn with_dpns_resolution(mut self, enabled: bool) -> Self { + self.dpns_resolution = enabled; + self + } + + /// Set the label displayed above the input field. + pub fn with_label(mut self, label: impl Into) -> Self { + self.label = Some(label.into()); + self + } + + /// Set the hint/placeholder text inside the input field. + pub fn with_hint_text(mut self, hint: impl Into) -> Self { + self.hint_text = Some(hint.into()); + self + } + + /// Set the desired width of the input field. + pub fn with_desired_width(mut self, width: f32) -> Self { + self.desired_width = Some(width); + self + } + + /// Enable or disable validation error display. Default: true. + pub fn with_show_validation_errors(mut self, show: bool) -> Self { + self.show_validation_errors = show; + self + } + + /// Enable developer mode display (exact credits alongside DASH). Default: false. + pub fn with_developer_mode(mut self, enabled: bool) -> Self { + self.developer_mode = enabled; + self + } + + /// Pre-populate the input field with an address string. + pub fn with_initial_value(mut self, address: impl Into) -> Self { + self.input_text = address.into(); + self + } + + /// Enable selection-only mode. When true, the user must pick from autocomplete; + /// manual arbitrary addresses are rejected. + pub fn with_selection_only(mut self, selection_only: bool) -> Self { + self.selection_only = selection_only; + self + } + + /// Show full addresses in dropdown instead of truncated. Default: false. + pub fn with_full_addresses(mut self, full: bool) -> Self { + self.full_addresses = full; + self + } + + // --- Mutable setters for runtime reconfiguration --- + + /// Update wallet data after initialization (e.g., balance refresh). + /// + /// When more than one wallet is provided, entries are prefixed with the wallet alias. + pub fn set_wallets(&mut self, wallets: &[Arc>]) { + self.all_entries.retain(|e| { + e.address_kind != AddressKind::Core && e.address_kind != AddressKind::Platform + }); + let multi = wallets.len() > 1; + for wallet in wallets { + self.extract_wallet_entries(wallet, multi); + } + } + + /// Update identity data after initialization. + pub fn set_identities(&mut self, identities: &[QualifiedIdentity]) { + self.all_entries + .retain(|e| e.address_kind != AddressKind::Identity); + self.extract_identity_entries(identities); + } + + /// Update shielded balance data after initialization. + pub fn set_shielded_balance(&mut self, address: String, balance: u64) { + self.all_entries + .retain(|e| e.address_kind != AddressKind::Shielded); + self.add_shielded_entry(address, balance); + } + + /// Update developer mode flag. + pub fn set_developer_mode(&mut self, enabled: bool) { + self.developer_mode = enabled; + } + + // --- Entry extraction --- + + fn extract_wallet_entries(&mut self, wallet: &Arc>, multi_wallet: bool) { + let guard = match wallet.read().ok() { + Some(g) => g, + None => return, + }; + + let prefix = if multi_wallet { + let name = guard.alias.as_deref().unwrap_or("Wallet"); + format!("[{}] ", name) + } else { + String::new() + }; + + // Core addresses from address_balances + for (address, &balance) in &guard.address_balances { + let addr_str = address.to_string(); + let display = if self.full_addresses { + format!("{}{}", prefix, addr_str) + } else { + format!("{}{}", prefix, truncate_address(&addr_str)) + }; + self.all_entries.push(AddressEntry { + address_string: addr_str, + address_kind: AddressKind::Core, + display_label: display, + balance, + validated: ValidatedAddress::Core(address.clone()), + }); + } + + // Platform addresses from platform_address_info + for (core_addr, info) in &guard.platform_address_info { + if let Ok(platform_addr) = PlatformAddress::try_from(core_addr.clone()) { + let addr_str = platform_addr.to_bech32m_string(self.network); + let display = if self.full_addresses { + format!("{}{}", prefix, addr_str) + } else { + format!("{}{}", prefix, truncate_address(&addr_str)) + }; + let bech32m = addr_str.clone(); + self.all_entries.push(AddressEntry { + address_string: addr_str, + address_kind: AddressKind::Platform, + display_label: display, + balance: info.balance, + validated: ValidatedAddress::Platform { + address: platform_addr, + bech32m, + }, + }); + } + } + } + + fn extract_identity_entries(&mut self, identities: &[QualifiedIdentity]) { + for qi in identities { + let id = qi.identity.id(); + let id_str = id.to_string(Encoding::Base58); + let dpns_name = qi.dpns_names.first().map(|n| n.name.clone()); + let display = if let Some(ref name) = dpns_name { + name.clone() + } else if let Some(ref alias) = qi.alias { + alias.clone() + } else if self.full_addresses { + id_str.clone() + } else { + truncate_address(&id_str) + }; + self.all_entries.push(AddressEntry { + address_string: id_str, + address_kind: AddressKind::Identity, + display_label: display, + balance: qi.identity.balance(), + validated: ValidatedAddress::Identity { + id, + dpns_name: dpns_name.clone(), + }, + }); + } + } + + fn add_shielded_entry(&mut self, address: String, balance: u64) { + let display = if self.full_addresses { + address.clone() + } else { + truncate_address(&address) + }; + self.all_entries.push(AddressEntry { + address_string: address.clone(), + address_kind: AddressKind::Shielded, + display_label: display, + balance, + validated: ValidatedAddress::Shielded(address), + }); + } + + // --- Detection and validation --- + + fn detect_cached(&mut self, input: &str) -> DetectedType { + if let Some((ref cached_input, cached_type)) = self.cached_detection + && cached_input == input + { + return cached_type; + } + let identity_enabled = self.enabled_kinds.contains(&AddressKind::Identity); + let result = detect_address_type(input, identity_enabled); + self.cached_detection = Some((input.to_string(), result)); + result + } + + fn validate_input(&self) -> (Option, Option) { + let trimmed = self.input_text.trim(); + if trimmed.is_empty() { + return (None, None); + } + + // In selection-only mode, all manual input is rejected. Users must select + // an address from the autocomplete dropdown. + if self.selection_only { + return ( + Some("Please select an address from the list.".to_string()), + None, + ); + } + + let identity_enabled = self.enabled_kinds.contains(&AddressKind::Identity); + let detected = detect_address_type(trimmed, identity_enabled); + + if detected == DetectedType::Unknown { + return ( + Some("This does not look like a valid address.".to_string()), + None, + ); + } + + let detected_kind = detected.to_address_kind().unwrap(); + + // Check enabled kinds + if !self.enabled_kinds.contains(&detected_kind) { + let msg = match self.enabled_kinds.as_slice() { + [AddressKind::Core] => "Only wallet addresses are accepted here.", + [AddressKind::Platform] => "Only platform addresses are accepted here.", + [AddressKind::Shielded] => "Only private addresses are accepted here.", + [AddressKind::Identity] => "Only identity IDs are accepted here.", + _ => "This address type is not accepted here.", + }; + return (Some(msg.to_string()), None); + } + + // Type-specific validation + match detected { + DetectedType::Core => self.validate_core(trimmed), + DetectedType::Platform => self.validate_platform(trimmed), + DetectedType::Shielded => self.validate_shielded(trimmed), + DetectedType::Identity => self.validate_identity(trimmed), + DetectedType::Unknown => unreachable!(), + } + } + + fn validate_core(&self, trimmed: &str) -> (Option, Option) { + match trimmed.parse::>() { + Ok(addr) => match addr.require_network(self.network) { + Ok(checked) => (None, Some(ValidatedAddress::Core(checked))), + Err(_) => ( + Some("This address belongs to a different network.".to_string()), + None, + ), + }, + Err(_) => ( + Some("This does not look like a valid address.".to_string()), + None, + ), + } + } + + fn validate_platform(&self, trimmed: &str) -> (Option, Option) { + // BIP-350: bech32m must be either all-lowercase or all-uppercase; mixed case is invalid. + let is_lower = trimmed.chars().all(|c| !c.is_ascii_uppercase()); + let is_upper = trimmed.chars().all(|c| !c.is_ascii_lowercase()); + if !is_lower && !is_upper { + return ( + Some( + "Platform addresses must not mix upper and lower case characters.".to_string(), + ), + None, + ); + } + let canonical = trimmed.to_lowercase(); + let expected_prefix = match self.network { + Network::Mainnet => "dash1", + _ => "tdash1", + }; + if !canonical.starts_with(expected_prefix) + || canonical.starts_with(&format!("{}z", expected_prefix)) + { + return ( + Some("This address belongs to a different network.".to_string()), + None, + ); + } + match PlatformAddress::from_bech32m_string(&canonical) { + Ok((pa, _network)) => ( + None, + Some(ValidatedAddress::Platform { + address: pa, + bech32m: canonical, + }), + ), + Err(_) => ( + Some("This does not look like a valid address.".to_string()), + None, + ), + } + } + + fn validate_shielded(&self, trimmed: &str) -> (Option, Option) { + let expected_prefix = match self.network { + Network::Mainnet => "dash1z", + _ => "tdash1z", + }; + if !trimmed.starts_with(expected_prefix) { + return ( + Some("This address belongs to a different network.".to_string()), + None, + ); + } + // Orchard shielded addresses are ~70+ chars; reject anything too short. + if trimmed.len() < 60 { + return ( + Some( + "This private address looks incomplete. Please paste the full address." + .to_string(), + ), + None, + ); + } + use dash_sdk::dpp::address_funds::OrchardAddress; + match OrchardAddress::from_bech32m_string(trimmed) { + Ok((_, network)) => { + if network != self.network + && !(self.network != Network::Mainnet && network != Network::Mainnet) + { + ( + Some("This address belongs to a different network.".to_string()), + None, + ) + } else { + (None, Some(ValidatedAddress::Shielded(trimmed.to_string()))) + } + } + Err(_) => ( + Some( + "This private address is not valid. Please check it and try again.".to_string(), + ), + None, + ), + } + } + + fn validate_identity(&self, trimmed: &str) -> (Option, Option) { + match Identifier::from_string(trimmed, Encoding::Base58) { + Ok(id) => { + let dpns = if self.dpns_resolution { + self.all_entries + .iter() + .find(|e| { + e.address_kind == AddressKind::Identity + && e.validated.as_identity_id() == Some(&id) + }) + .and_then(|e| e.validated.dpns_name().map(|s| s.to_string())) + } else { + None + }; + ( + None, + Some(ValidatedAddress::Identity { + id, + dpns_name: dpns, + }), + ) + } + Err(_) => ( + Some("This does not look like a valid address.".to_string()), + None, + ), + } + } + + // --- Autocomplete filtering --- + + /// Returns matching entries (truncated to 10) and the total match count + /// before truncation. + fn filtered_entries(&self) -> (Vec<&AddressEntry>, usize) { + let query = self.input_text.trim().to_lowercase(); + + let mut results: Vec<&AddressEntry> = self + .all_entries + .iter() + .filter(|e| { + // Type filter + if let Some(filter_kind) = self.selected_type_filter + && e.address_kind != filter_kind + { + return false; + } + // Enabled kinds + if !self.enabled_kinds.contains(&e.address_kind) { + return false; + } + // Balance range + if let Some(ref range) = self.balance_range + && !range.contains(e.balance) + { + return false; + } + // When query is empty, show all entries (no substring filter) + if query.is_empty() { + return true; + } + // Substring match against address and label + e.address_string.to_lowercase().contains(&query) + || e.display_label.to_lowercase().contains(&query) + }) + .collect(); + + // Sort: exact prefix matches first, then by label + results.sort_by(|a, b| { + if query.is_empty() { + return a.display_label.cmp(&b.display_label); + } + let a_prefix = a.address_string.to_lowercase().starts_with(&query); + let b_prefix = b.address_string.to_lowercase().starts_with(&query); + b_prefix + .cmp(&a_prefix) + .then(a.display_label.cmp(&b.display_label)) + }); + + let total = results.len(); + results.truncate(10); + (results, total) + } + + // --- Balance formatting --- + + fn format_balance(&self, entry: &AddressEntry) -> String { + match entry.address_kind { + AddressKind::Core => Self::format_dash_4dp(Amount::dash_from_duffs(entry.balance)), + AddressKind::Platform | AddressKind::Shielded | AddressKind::Identity => { + let dash = Amount::new(entry.balance, DASH_DECIMAL_PLACES).with_unit_name("DASH"); + if self.developer_mode { + format!( + "{} ({} credits)", + Self::format_dash_4dp(dash), + entry.balance + ) + } else { + Self::format_dash_4dp(dash) + } + } + } + } + + /// Format a DASH amount with exactly 4 decimal places for dropdown display. + fn format_dash_4dp(amount: Amount) -> String { + // Get the full-precision string without trimming, then truncate to 4 dp. + let full = amount.to_string_opts(false, false); + let formatted = if let Some(dot_pos) = full.find('.') { + let decimals = &full[dot_pos + 1..]; + if decimals.len() > 4 { + format!("{}.{}", &full[..dot_pos], &decimals[..4]) + } else { + // Pad with zeros if fewer than 4 decimals + format!("{}.{:0<4}", &full[..dot_pos], decimals) + } + } else { + format!("{full}.0000") + }; + format!("{formatted} DASH") + } + + // --- show() implementation --- + + fn show_internal(&mut self, ui: &mut Ui) -> InnerResponse { + let resp = ui.vertical(|ui| { + // Label + if let Some(label) = &self.label { + ui.label(label.clone()); + } + + // Input row + let text_response = ui + .horizontal(|ui| { + // Type filter dropdown + if self.show_type_filter && self.enabled_kinds.len() > 1 { + let current_label = self + .selected_type_filter + .map(|t| t.display_name()) + .unwrap_or("All"); + egui::ComboBox::from_id_salt(ui.id().with("address_type_filter")) + .selected_text(current_label) + .width(120.0) + .show_ui(ui, |ui| { + if ui + .selectable_label(self.selected_type_filter.is_none(), "All") + .clicked() + { + self.selected_type_filter = None; + } + for &kind in &self.enabled_kinds { + let selected = self.selected_type_filter == Some(kind); + if ui.selectable_label(selected, kind.display_name()).clicked() + { + self.selected_type_filter = Some(kind); + } + } + }); + } + + // Text input + let mut text_edit = egui::TextEdit::singleline(&mut self.input_text); + if let Some(hint) = &self.hint_text { + text_edit = text_edit + .hint_text(egui::RichText::new(hint).color(egui::Color32::GRAY)); + } + if let Some(width) = self.desired_width { + text_edit = text_edit.desired_width(width); + } else { + text_edit = text_edit.desired_width(f32::INFINITY); + } + ui.add(text_edit) + }) + .inner; + + let text_changed = text_response.changed(); + let lost_focus = text_response.lost_focus(); + let has_focus = text_response.has_focus(); + + // On text change: reset validation state + if text_changed { + self.selected_from_autocomplete = false; + self.cached_detection = None; + // Detect paste: a multi-character change in a single frame. + // Validate immediately so the user doesn't have to blur first. + self.has_blurred = self.input_text.trim().len() > 3; + } + + // Detect address type (cached) + let input_clone = self.input_text.clone(); + let detected = self.detect_cached(&input_clone); + + // On blur: trigger validation + if lost_focus && !self.input_text.trim().is_empty() { + self.has_blurred = true; + } + + // Autocomplete popup + let mut selected_entry: Option = None; + if has_focus || self.autocomplete_open { + // Collect filtered entries into an owned snapshot to release the borrow on self + let (filtered, total_entries) = self.filtered_entries(); + let filtered_len = filtered.len(); + let show_type_suffix = self.enabled_kinds.len() > 1; + let entries_snapshot: Vec<(String, String, AddressEntry)> = filtered + .iter() + .map(|e| { + let label = if show_type_suffix { + format!("{} ({})", e.display_label, e.address_kind.short_label()) + } else { + e.display_label.clone() + }; + (label, self.format_balance(e), (*e).clone()) + }) + .collect(); + + if !entries_snapshot.is_empty() { + self.autocomplete_open = true; + let popup_id = ui.id().with("address_autocomplete"); + + egui::Area::new(popup_id) + .order(egui::Order::Foreground) + .fixed_pos(text_response.rect.left_bottom()) + .show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_width(text_response.rect.width()); + egui::ScrollArea::vertical() + .max_height(200.0) + .show(ui, |ui| { + for (i, (label, balance_str, entry)) in + entries_snapshot.iter().enumerate() + { + let highlighted = + self.autocomplete_highlight == Some(i); + + // Single interaction rect spanning the full + // row width. No child widgets — painted + // manually so nothing steals clicks. + let row_height = ui.spacing().interact_size.y; + let row_width = ui.available_width(); + let (rect, response) = ui.allocate_exact_size( + egui::vec2(row_width, row_height), + egui::Sense::click(), + ); + + if ui.is_rect_visible(rect) { + let hovered = response.hovered(); + if highlighted || hovered { + ui.painter().rect_filled( + rect, + egui::CornerRadius::from(2.0), + ui.style().visuals.widgets.hovered.bg_fill, + ); + } + + let text_color = if highlighted || hovered { + ui.style().visuals.widgets.hovered.text_color() + } else { + ui.style().visuals.widgets.inactive.text_color() + }; + + let padding = 4.0; + ui.painter().text( + egui::pos2( + rect.left() + padding, + rect.center().y, + ), + egui::Align2::LEFT_CENTER, + label.as_str(), + egui::TextStyle::Body.resolve(ui.style()), + text_color, + ); + + ui.painter().text( + egui::pos2( + rect.right() - padding, + rect.center().y, + ), + egui::Align2::RIGHT_CENTER, + balance_str.as_str(), + egui::TextStyle::Small.resolve(ui.style()), + DashColors::GRAY, + ); + } + + if response.clicked() { + selected_entry = Some(entry.clone()); + } + } + if total_entries > 10 { + let remaining = total_entries - 10; + ui.label( + egui::RichText::new(format!( + "...and {} more", + remaining + )) + .small() + .color(DashColors::GRAY), + ); + } + }); + }); + }); + + // Keyboard navigation (uses snapshot data, no recomputation) + ui.input(|i| { + if i.key_pressed(egui::Key::ArrowDown) { + self.autocomplete_highlight = Some( + self.autocomplete_highlight + .map(|h| (h + 1).min(filtered_len.saturating_sub(1))) + .unwrap_or(0), + ); + } + if i.key_pressed(egui::Key::ArrowUp) { + self.autocomplete_highlight = self + .autocomplete_highlight + .map(|h| h.saturating_sub(1)) + .or(Some(0)); + } + if i.key_pressed(egui::Key::Escape) { + self.autocomplete_open = false; + self.autocomplete_highlight = None; + } + if i.key_pressed(egui::Key::Enter) + && let Some(idx) = self.autocomplete_highlight + && let Some((_, _, entry)) = entries_snapshot.get(idx) + { + selected_entry = Some(entry.clone()); + } + }); + } else { + self.autocomplete_open = false; + } + } else { + self.autocomplete_open = false; + } + + // Handle autocomplete selection (FIX 7: clear cached_detection) + let selected_this_frame = selected_entry.is_some(); + if let Some(entry) = selected_entry { + self.input_text = entry.address_string.clone(); + self.selected_from_autocomplete = true; + self.cached_detection = None; + self.autocomplete_open = false; + self.autocomplete_highlight = None; + self.has_blurred = true; + } + + // Validation + let (error_message, validated_address) = if self.selected_from_autocomplete { + // Find the matching entry for the selected address + let validated = self + .all_entries + .iter() + .find(|e| e.address_string == self.input_text) + .map(|e| e.validated.clone()); + (None, validated) + } else if self.has_blurred && !self.input_text.trim().is_empty() { + self.validate_input() + } else { + (None, None) + }; + + // Status/error display below input + if self.show_validation_errors { + if let Some(ref error) = error_message { + ui.colored_label(DashColors::VALIDATION_WARNING, error); + } else if self.has_blurred + && validated_address.is_some() + && let Some(kind) = detected.to_address_kind() + { + ui.colored_label(DashColors::SUCCESS, kind.display_name()); + } + } + + // Build response + // FIX 1: blur validation produces a result => signal changed + let blur_validated = lost_focus && validated_address.is_some(); + // FIX 2: use one-frame local flag for autocomplete selection + let changed = text_changed || selected_this_frame || self.changed || blur_validated; + if self.changed { + self.changed = false; + } + + AddressInputResponse { + response: text_response, + changed, + error_message, + validated_address, + } + }); + + InnerResponse::new(resp.inner, resp.response) + } +} + +impl Component for AddressInput { + type DomainType = ValidatedAddress; + type Response = AddressInputResponse; + + fn show(&mut self, ui: &mut Ui) -> InnerResponse { + self.show_internal(ui) + } + + fn current_value(&self) -> Option { + if self.selected_from_autocomplete { + return self + .all_entries + .iter() + .find(|e| e.address_string == self.input_text) + .map(|e| e.validated.clone()); + } + if self.has_blurred && !self.input_text.trim().is_empty() { + let (err, val) = self.validate_input(); + if err.is_none() { + return val; + } + } + None + } +} + +// --- Free functions --- + +/// Detect the address type of a raw input string. +/// +/// Priority: Shielded > Platform > Core > Identity (Base58 fallback). +/// Identity detection only runs when `identity_enabled` is true. +fn detect_address_type(input: &str, identity_enabled: bool) -> DetectedType { + // Delegate to AddressKind::detect() with a dummy network (detection is + // network-agnostic — it only checks format, not network correctness). + match AddressKind::detect(input, Network::Testnet) { + Some(AddressKind::Identity) if !identity_enabled => DetectedType::Unknown, + Some(AddressKind::Core) => DetectedType::Core, + Some(AddressKind::Platform) => DetectedType::Platform, + Some(AddressKind::Shielded) => DetectedType::Shielded, + Some(AddressKind::Identity) => DetectedType::Identity, + None => DetectedType::Unknown, + } +} + +/// Truncate an address string for display, showing prefix and suffix. +fn truncate_address(addr: &str) -> String { + if addr.chars().count() <= 16 { + return addr.to_string(); + } + let prefix: String = addr.chars().take(8).collect(); + let suffix: String = addr + .chars() + .rev() + .take(6) + .collect::() + .chars() + .rev() + .collect(); + format!("{prefix}...{suffix}") +} + +#[cfg(test)] +mod tests { + use super::*; + use dash_sdk::dashcore_rpc::dashcore::secp256k1::{Secp256k1, SecretKey}; + use dash_sdk::dashcore_rpc::dashcore::{PrivateKey, PublicKey}; + + /// Generate a valid testnet P2PKH address for testing. + fn testnet_core_address() -> (String, Address) { + let secp = Secp256k1::new(); + let sk = SecretKey::from_slice(&[1u8; 32]).unwrap(); + let privkey = PrivateKey::new(sk, Network::Testnet); + let pubkey = PublicKey::from_private_key(&secp, &privkey); + let addr = Address::p2pkh(&pubkey, Network::Testnet); + (addr.to_string(), addr) + } + + /// Generate a valid mainnet P2PKH address for testing. + fn mainnet_core_address() -> (String, Address) { + let secp = Secp256k1::new(); + let sk = SecretKey::from_slice(&[2u8; 32]).unwrap(); + let privkey = PrivateKey::new(sk, Network::Mainnet); + let pubkey = PublicKey::from_private_key(&secp, &privkey); + let addr = Address::p2pkh(&pubkey, Network::Mainnet); + (addr.to_string(), addr) + } + + // --- detect_address_type tests --- + + #[test] + fn detect_shielded_mainnet() { + let result = detect_address_type("dash1z_some_shielded_addr", true); + assert_eq!(result, DetectedType::Shielded); + } + + #[test] + fn detect_shielded_testnet() { + let result = detect_address_type("tdash1z_some_shielded_addr", true); + assert_eq!(result, DetectedType::Shielded); + } + + #[test] + fn detect_platform_testnet() { + // A plausible platform address prefix + let result = detect_address_type("tdash1qwer1234", false); + assert_eq!(result, DetectedType::Platform); + } + + #[test] + fn detect_platform_mainnet() { + let result = detect_address_type("dash1qwer1234", false); + assert_eq!(result, DetectedType::Platform); + } + + #[test] + fn detect_core_address() { + let (addr_str, _) = testnet_core_address(); + let result = detect_address_type(&addr_str, false); + assert_eq!(result, DetectedType::Core); + } + + #[test] + fn detect_unknown_for_garbage() { + let result = detect_address_type("not-an-address", true); + assert_eq!(result, DetectedType::Unknown); + } + + #[test] + fn detect_empty_is_unknown() { + let result = detect_address_type("", true); + assert_eq!(result, DetectedType::Unknown); + } + + #[test] + fn detect_whitespace_is_unknown() { + let result = detect_address_type(" ", true); + assert_eq!(result, DetectedType::Unknown); + } + + #[test] + fn detect_identity_when_enabled() { + // A 32-byte Base58 identifier that does not parse as a Core address + let id = Identifier::random(); + let id_str = id.to_string(Encoding::Base58); + let result = detect_address_type(&id_str, true); + assert_eq!(result, DetectedType::Identity); + } + + #[test] + fn detect_identity_disabled_falls_through_to_unknown() { + let id = Identifier::random(); + let id_str = id.to_string(Encoding::Base58); + let result = detect_address_type(&id_str, false); + // Should be Unknown since identity detection is disabled + // (unless it happens to parse as a Core address, which is possible for some Base58 values) + assert!(result == DetectedType::Unknown || result == DetectedType::Core); + } + + #[test] + fn shielded_takes_priority_over_platform() { + // dash1z starts with "dash1" which could match platform, but shielded wins + let result = detect_address_type("dash1z_test_addr", false); + assert_eq!(result, DetectedType::Shielded); + } + + // --- Network validation tests --- + + #[test] + fn core_address_wrong_network_rejected() { + let input = AddressInput::new(Network::Testnet); + let (mainnet_str, _) = mainnet_core_address(); + let (err, val) = input.validate_core(&mainnet_str); + assert!(val.is_none()); + assert_eq!( + err.as_deref(), + Some("This address belongs to a different network.") + ); + } + + #[test] + fn core_address_correct_network_accepted() { + let input = AddressInput::new(Network::Testnet); + let (testnet_str, _) = testnet_core_address(); + let (err, val) = input.validate_core(&testnet_str); + assert!(err.is_none()); + assert!(val.is_some()); + } + + #[test] + fn platform_address_wrong_network_rejected() { + let input = AddressInput::new(Network::Mainnet); + // tdash1 prefix on mainnet + let (err, val) = input.validate_platform("tdash1qwer1234"); + assert!(val.is_none()); + assert_eq!( + err.as_deref(), + Some("This address belongs to a different network.") + ); + } + + #[test] + fn shielded_address_wrong_network_rejected() { + let input = AddressInput::new(Network::Mainnet); + let (err, val) = input.validate_shielded("tdash1z_test_addr"); + assert!(val.is_none()); + assert_eq!( + err.as_deref(), + Some("This address belongs to a different network.") + ); + } + + #[test] + fn shielded_address_too_short_rejected() { + let input = AddressInput::new(Network::Testnet); + let (err, val) = input.validate_shielded("tdash1z"); + assert!(val.is_none()); + assert_eq!( + err.as_deref(), + Some("This private address looks incomplete. Please paste the full address.") + ); + } + + #[test] + fn shielded_prefix_only_rejected() { + let input = AddressInput::new(Network::Mainnet); + let (err, val) = input.validate_shielded("dash1z"); + assert!(val.is_none()); + assert_eq!( + err.as_deref(), + Some("This private address looks incomplete. Please paste the full address.") + ); + } + + #[test] + fn shielded_address_with_invalid_chars_rejected() { + let input = AddressInput::new(Network::Testnet); + let long_addr = format!("tdash1z{}", "x".repeat(60)); + let (err, val) = input.validate_shielded(&long_addr); + assert!(val.is_none()); + assert_eq!( + err.as_deref(), + Some("This private address is not valid. Please check it and try again.") + ); + } + + // --- Enabled type restriction tests --- + + #[test] + fn disabled_type_rejected_with_correct_error() { + let mut input = + AddressInput::new(Network::Testnet).with_address_kinds(&[AddressKind::Core]); + // Set a platform-looking address with only Core enabled + input.input_text = "tdash1qwer1234".to_string(); + input.has_blurred = true; + let (err, val) = input.validate_input(); + assert!( + val.is_none(), + "should reject platform address when only Core is enabled" + ); + assert_eq!( + err.as_deref(), + Some("Only wallet addresses are accepted here.") + ); + } + + #[test] + fn disabled_type_empty_input_no_error() { + let input = AddressInput::new(Network::Testnet).with_address_kinds(&[AddressKind::Core]); + let (err, val) = input.validate_input(); + assert!(err.is_none(), "empty input should not produce an error"); + assert!(val.is_none()); + } + + // --- Selection-only mode tests --- + + #[test] + fn selection_only_rejects_manual_input() { + let mut input = AddressInput::new(Network::Testnet).with_selection_only(true); + let (addr_str, _) = testnet_core_address(); + input.input_text = addr_str; + input.has_blurred = true; + let (err, val) = input.validate_input(); + assert!( + val.is_none(), + "selection-only mode should reject manual input" + ); + assert_eq!( + err.as_deref(), + Some("Please select an address from the list.") + ); + } + + #[test] + fn selection_only_empty_input_no_error() { + let input = AddressInput::new(Network::Testnet).with_selection_only(true); + let (err, val) = input.validate_input(); + assert!( + err.is_none(), + "empty input in selection-only mode should not error" + ); + assert!(val.is_none()); + } + + // --- Identity validation tests --- + + #[test] + fn validate_identity_valid_identifier() { + let input = + AddressInput::new(Network::Testnet).with_address_kinds(&[AddressKind::Identity]); + let id = Identifier::random(); + let id_str = id.to_string(Encoding::Base58); + let (err, val) = input.validate_identity(&id_str); + assert!(err.is_none()); + let val = val.expect("valid identifier should produce ValidatedAddress"); + assert_eq!(val.kind(), AddressKind::Identity); + assert_eq!(val.as_identity_id(), Some(&id)); + } + + #[test] + fn validate_identity_invalid_string() { + let input = AddressInput::new(Network::Testnet); + let (err, val) = input.validate_identity("not-a-valid-identifier"); + assert!(val.is_none()); + assert_eq!( + err.as_deref(), + Some("This does not look like a valid address.") + ); + } + + // --- Truncate boundary tests --- + + #[test] + fn truncate_address_boundary_16_unchanged() { + assert_eq!(truncate_address("1234567890123456"), "1234567890123456"); + } + + #[test] + fn truncate_address_boundary_17_truncated() { + let result = truncate_address("12345678901234567"); + assert_eq!(result, "12345678...234567"); + } + + #[test] + fn truncate_address_non_ascii_does_not_panic() { + let addr = "\u{1F355}dash1ztestaddr\u{1F389}longstringpadding"; + let result = truncate_address(addr); + assert!(result.contains("...")); + } + + #[test] + fn truncate_address_multibyte_short_unchanged() { + let addr = "\u{00E9}\u{00E9}\u{00E9}abc"; + assert_eq!(truncate_address(addr), addr); + } + + // --- BalanceRange tests --- + + #[test] + fn balance_range_inclusive() { + let range = BalanceRange::from_range(&(10..=20)); + assert!(range.contains(10)); + assert!(range.contains(15)); + assert!(range.contains(20)); + assert!(!range.contains(9)); + assert!(!range.contains(21)); + } + + #[test] + fn balance_range_exclusive() { + let range = BalanceRange::from_range(&(10..20)); + assert!(range.contains(10)); + assert!(range.contains(19)); + assert!(!range.contains(20)); + assert!(!range.contains(9)); + } + + #[test] + fn balance_range_unbounded_start() { + let range = BalanceRange::from_range(&(..=100)); + assert!(range.contains(0)); + assert!(range.contains(100)); + assert!(!range.contains(101)); + } + + #[test] + fn balance_range_unbounded_end() { + let range = BalanceRange::from_range(&(50..)); + assert!(!range.contains(49)); + assert!(range.contains(50)); + assert!(range.contains(u64::MAX)); + } + + #[test] + fn balance_range_fully_unbounded() { + let range = BalanceRange::from_range(&(..)); + assert!(range.contains(0)); + assert!(range.contains(u64::MAX)); + } + + #[test] + fn balance_range_zero_only() { + let range = BalanceRange::from_range(&(0..=0)); + assert!(range.contains(0)); + assert!(!range.contains(1)); + } + + // --- AddressKind display name tests --- + + #[test] + fn address_kind_display_names() { + assert_eq!(AddressKind::Core.display_name(), "Wallet address"); + assert_eq!(AddressKind::Platform.display_name(), "Platform address"); + assert_eq!(AddressKind::Shielded.display_name(), "Private address"); + assert_eq!(AddressKind::Identity.display_name(), "Identity"); + } + + // --- truncate_address tests --- + + #[test] + fn truncate_short_address_unchanged() { + assert_eq!(truncate_address("short"), "short"); + } + + #[test] + fn truncate_long_address() { + let (addr_str, _) = testnet_core_address(); + let truncated = truncate_address(&addr_str); + assert!(truncated.contains("...")); + assert!(truncated.len() < addr_str.len()); + } + + // --- ValidatedAddress variant accessor tests --- + + #[test] + fn validated_core_accessors() { + let (_, addr) = testnet_core_address(); + let va = ValidatedAddress::Core(addr.clone()); + assert_eq!(va.kind(), AddressKind::Core); + assert_eq!(va.as_core(), Some(&addr)); + assert!(va.as_platform().is_none()); + assert!(va.as_identity_id().is_none()); + assert!(va.dpns_name().is_none()); + } + + #[test] + fn validated_identity_accessors() { + let id = Identifier::random(); + let va = ValidatedAddress::Identity { + id, + dpns_name: Some("alice.dash".to_string()), + }; + assert_eq!(va.kind(), AddressKind::Identity); + assert_eq!(va.as_identity_id(), Some(&id)); + assert_eq!(va.dpns_name(), Some("alice.dash")); + assert!(va.as_core().is_none()); + } + + #[test] + fn validated_shielded_accessors() { + let va = ValidatedAddress::Shielded("dash1z_test".to_string()); + assert_eq!(va.kind(), AddressKind::Shielded); + assert_eq!(va.to_address_string(), "dash1z_test"); + } + + // --- FIX 1: Blur validation propagation --- + + #[test] + fn blur_triggers_validation_for_valid_core_address() { + let (addr_str, _) = testnet_core_address(); + let mut input = AddressInput::new(Network::Testnet); + input.input_text = addr_str; + // Simulate blur: has_blurred is set when focus leaves with non-empty input + input.has_blurred = true; + let (err, val) = input.validate_input(); + assert!(err.is_none(), "valid address after blur should not error"); + assert!( + val.is_some(), + "valid address after blur should produce a validated address" + ); + } + + #[test] + fn current_value_returns_validated_after_blur() { + let (addr_str, _) = testnet_core_address(); + let mut input = AddressInput::new(Network::Testnet); + input.input_text = addr_str; + input.has_blurred = true; + let val = input.current_value(); + assert!( + val.is_some(), + "current_value should return validated address after blur" + ); + assert_eq!(val.unwrap().kind(), AddressKind::Core); + } + + // --- FIX 4: Mixed-case bech32m rejection --- + + #[test] + fn platform_mixed_case_rejected() { + let input = AddressInput::new(Network::Testnet); + let (err, val) = input.validate_platform("tDash1qwer1234"); + assert!(val.is_none(), "mixed-case bech32m should be rejected"); + assert_eq!( + err.as_deref(), + Some("Platform addresses must not mix upper and lower case characters.") + ); + } + + #[test] + fn platform_all_lowercase_accepted_for_case_check() { + let input = AddressInput::new(Network::Testnet); + // This will fail bech32m parsing, but should NOT fail the case check + let (err, _) = input.validate_platform("tdash1qwer1234"); + assert_ne!( + err.as_deref(), + Some("Platform addresses must not mix upper and lower case characters."), + "all-lowercase should pass the case check" + ); + } + + #[test] + fn platform_all_uppercase_accepted_for_case_check() { + let input = AddressInput::new(Network::Testnet); + // All-uppercase is valid per BIP-350 (will fail other checks, but not case) + let (err, _) = input.validate_platform("TDASH1QWER1234"); + assert_ne!( + err.as_deref(), + Some("Platform addresses must not mix upper and lower case characters."), + "all-uppercase should pass the case check" + ); + } +} diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs index 5f123ba1c..20e7d880a 100644 --- a/src/ui/components/mod.rs +++ b/src/ui/components/mod.rs @@ -1,3 +1,4 @@ +pub mod address_input; pub mod amount_input; pub mod component_trait; pub mod confirmation_dialog; diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index ff46ad922..fa029898d 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -3,9 +3,11 @@ use crate::backend_task::BackendTask; use crate::backend_task::core::{CoreTask, PaymentRecipient, WalletPaymentRequest}; use crate::backend_task::wallet::WalletTask; use crate::context::AppContext; +use crate::model::address::{AddressKind, ValidatedAddress}; use crate::model::amount::{Amount, DASH_DECIMAL_PLACES}; use crate::model::fee_estimation::format_credits_as_dash; use crate::model::wallet::{Wallet, WalletSeedHash}; +use crate::ui::components::address_input::AddressInput; use crate::ui::components::amount_input::AmountInput; use crate::ui::components::component_trait::{Component, ComponentResponse}; use crate::ui::components::left_panel::add_left_panel; @@ -262,15 +264,6 @@ fn allocate_platform_addresses( }) } -/// Detected address type -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AddressType { - Core, - Platform, - Shielded, - Unknown, -} - /// Source selection for sending #[derive(Debug, Clone, PartialEq)] pub enum SourceSelection { @@ -373,7 +366,8 @@ pub struct WalletSendScreen { // Unified send fields (simple mode) selected_source: Option, - destination_address: String, + address_input: Option, + validated_destination: Option, amount: Option, amount_input: Option, @@ -407,7 +401,8 @@ impl WalletSendScreen { selected_wallet: Some(wallet), selected_wallet_seed_hash: seed_hash, selected_source: Some(SourceSelection::CoreWallet), - destination_address: String::new(), + address_input: None, + validated_destination: None, amount: None, amount_input: None, show_advanced_options: false, @@ -445,14 +440,12 @@ impl WalletSendScreen { return estimate_platform_fee(fee_estimator, 1); } - let dest_type = Self::detect_address_type(&self.destination_address); - if dest_type == AddressType::Core { + let dest_kind = self.validated_destination.as_ref().map(|v| v.kind()); + if dest_kind == Some(AddressKind::Core) { let output_script = self - .destination_address - .trim() - .parse::>() - .ok() - .and_then(|addr| addr.require_network(self.app_context.network).ok()) + .validated_destination + .as_ref() + .and_then(|v| v.as_core()) .map(|addr| CoreScript::new(addr.script_pubkey())); if let Some(output_script) = output_script { let max_fee_inputs: BTreeMap = sorted_addresses @@ -472,7 +465,8 @@ impl WalletSendScreen { } fn reset_form(&mut self) { - self.destination_address.clear(); + self.address_input = None; + self.validated_destination = None; self.amount = None; self.amount_input = None; self.selected_source = Some(SourceSelection::CoreWallet); @@ -511,39 +505,17 @@ impl WalletSendScreen { Ok(duffs as Credits * 1000) } - /// Detect address type from the address string - fn detect_address_type(address: &str) -> AddressType { - let trimmed = address.trim(); - if trimmed.is_empty() { - return AddressType::Unknown; - } - - // Check for shielded address (dash1z... or tdash1z...) - if Self::is_shielded_address(trimmed) { - return AddressType::Shielded; - } - - // Check for Platform address (Bech32m format per DIP-18) - if crate::ui::helpers::is_platform_address_string(trimmed) { - return AddressType::Platform; - } - - // Try to parse as Core address - if trimmed.parse::>().is_ok() { - return AddressType::Core; - } - - AddressType::Unknown - } - - fn is_shielded_address(s: &str) -> bool { - s.starts_with("dash1z") || s.starts_with("tdash1z") + /// Detect address kind from the address string. + /// + /// Returns `None` for empty or unrecognized input. + fn detect_address_kind(&self, address: &str) -> Option { + AddressKind::detect(address, self.app_context.network) } fn min_output_amount( &self, - input_type: AddressType, - output_type: AddressType, + input_type: Option, + output_type: Option, ) -> Option { let core_min = 5460_u64 * CREDITS_PER_DUFF; let platform_min = self @@ -554,20 +526,22 @@ impl WalletSendScreen { .address_funds .min_output_amount; + use AddressKind::*; match (input_type, output_type) { - (AddressType::Unknown, AddressType::Unknown) => None, - (AddressType::Core, AddressType::Core) => Some(core_min), - (AddressType::Platform, AddressType::Platform) => Some(platform_min), - (AddressType::Core, AddressType::Platform) => Some(56000000), // needed for asset locks - (AddressType::Platform, AddressType::Core) => Some(core_min.max(platform_min)), - (AddressType::Unknown, AddressType::Core) => Some(core_min), - (AddressType::Unknown, AddressType::Platform) => Some(platform_min), - (AddressType::Core, AddressType::Unknown) => Some(core_min), - (AddressType::Platform, AddressType::Unknown) => Some(platform_min), - (AddressType::Shielded, AddressType::Shielded) => Some(platform_min), - (AddressType::Shielded, AddressType::Platform) => Some(platform_min), - (AddressType::Shielded, _) => Some(platform_min), - (_, AddressType::Shielded) => Some(platform_min), + (None, None) => None, + (Some(Core), Some(Core)) => Some(core_min), + (Some(Platform), Some(Platform)) => Some(platform_min), + (Some(Core), Some(Platform)) => Some(56000000), // needed for asset locks + (Some(Platform), Some(Core)) => Some(core_min.max(platform_min)), + (None, Some(Core)) => Some(core_min), + (None, Some(Platform)) => Some(platform_min), + (Some(Core), None) => Some(core_min), + (Some(Platform), None) => Some(platform_min), + (Some(Shielded), Some(Shielded)) => Some(platform_min), + (Some(Shielded), Some(Platform)) => Some(platform_min), + (Some(Shielded), _) => Some(platform_min), + (_, Some(Shielded)) => Some(platform_min), + (Some(Identity), _) | (_, Some(Identity)) => Some(platform_min), } } @@ -685,22 +659,42 @@ impl WalletSendScreen { /// Get description of transaction type based on source and destination fn get_transaction_type_description(&self) -> &'static str { - let dest_type = Self::detect_address_type(&self.destination_address); - match (&self.selected_source, dest_type) { - (Some(SourceSelection::CoreWallet), AddressType::Core) => "Core Transaction", - (Some(SourceSelection::CoreWallet), AddressType::Platform) => "Fund Platform Address", - (Some(SourceSelection::PlatformAddresses(_)), AddressType::Platform) => { + let dest_kind = self.destination_kind(); + match (&self.selected_source, dest_kind) { + (Some(SourceSelection::CoreWallet), Some(AddressKind::Core)) => "Core Transaction", + (Some(SourceSelection::CoreWallet), Some(AddressKind::Platform)) => { + "Fund Platform Address" + } + (Some(SourceSelection::PlatformAddresses(_)), Some(AddressKind::Platform)) => { "Platform Transfer" } - (Some(SourceSelection::PlatformAddresses(_)), AddressType::Core) => "Withdraw to Core", - (Some(SourceSelection::Shielded(..)), AddressType::Shielded) => { + (Some(SourceSelection::PlatformAddresses(_)), Some(AddressKind::Core)) => { + "Withdraw to Core" + } + (Some(SourceSelection::Shielded(..)), Some(AddressKind::Shielded)) => { "Private Transfer (Shielded)" } - (Some(SourceSelection::Shielded(..)), AddressType::Platform) => "Unshield to Platform", + (Some(SourceSelection::Shielded(..)), Some(AddressKind::Platform)) => { + "Unshield to Platform" + } _ => "Send", } } + /// Returns the address kind of the current validated destination, if any. + fn destination_kind(&self) -> Option { + self.validated_destination.as_ref().map(|v| v.kind()) + } + + /// Returns the destination address string, from the validated address if + /// available or an empty string otherwise. + fn destination_address_string(&self) -> String { + self.validated_destination + .as_ref() + .map(|v| v.to_address_string()) + .unwrap_or_default() + } + /// Clear the current send banner and show a new "Sending transaction..." progress banner. /// /// Called before dispatching any send backend task so the elapsed counter always starts fresh. @@ -722,7 +716,6 @@ impl WalletSendScreen { } let seed_hash = wallet_guard.seed_hash(); - let network = self.app_context.network; // Validate source let source = self @@ -731,8 +724,8 @@ impl WalletSendScreen { .ok_or("Please select a source")?; // Validate destination - let dest_type = Self::detect_address_type(&self.destination_address); - if dest_type == AddressType::Unknown { + let dest_kind = self.destination_kind(); + if dest_kind.is_none() { return Err( "Invalid destination address. Use a Dash address (X.../y...) or Platform address (dash1.../tdash1...)" .to_string(), @@ -751,21 +744,21 @@ impl WalletSendScreen { drop(wallet_guard); // Route to appropriate handler based on source and destination types - match (source.clone(), dest_type) { - (SourceSelection::CoreWallet, AddressType::Core) => self.send_core_to_core(), - (SourceSelection::CoreWallet, AddressType::Platform) => { + match (source.clone(), dest_kind) { + (SourceSelection::CoreWallet, Some(AddressKind::Core)) => self.send_core_to_core(), + (SourceSelection::CoreWallet, Some(AddressKind::Platform)) => { self.send_core_to_platform(seed_hash) } - (SourceSelection::PlatformAddresses(addresses), AddressType::Platform) => { + (SourceSelection::PlatformAddresses(addresses), Some(AddressKind::Platform)) => { self.send_platform_to_platform(seed_hash, addresses) } - (SourceSelection::PlatformAddresses(addresses), AddressType::Core) => { - self.send_platform_to_core(seed_hash, addresses, network) + (SourceSelection::PlatformAddresses(addresses), Some(AddressKind::Core)) => { + self.send_platform_to_core(seed_hash, addresses) } - (SourceSelection::Shielded(sh, _), AddressType::Shielded) => { + (SourceSelection::Shielded(sh, _), Some(AddressKind::Shielded)) => { self.send_shielded_to_shielded(sh) } - (SourceSelection::Shielded(sh, _), AddressType::Platform) => { + (SourceSelection::Shielded(sh, _), Some(AddressKind::Platform)) => { self.send_shielded_to_platform(sh) } _ => Err("Invalid source/destination combination".to_string()), @@ -799,7 +792,7 @@ impl WalletSendScreen { .clone(); let recipient = PaymentRecipient { - address: self.destination_address.trim().to_string(), + address: self.destination_address_string(), amount_duffs, }; @@ -828,11 +821,12 @@ impl WalletSendScreen { return Err("Amount must be greater than 0".to_string()); } - // Parse platform address - let address_str = self.destination_address.trim(); - let destination = PlatformAddress::from_bech32m_string(address_str) - .map(|(addr, _)| addr) - .map_err(|e| format!("Invalid platform address: {}", e))?; + // Extract validated platform address + let destination = self + .validated_destination + .as_ref() + .and_then(|v| v.as_platform().copied()) + .ok_or_else(|| "Invalid platform address".to_string())?; // Check balance; fees will be subtracted from amount let required = amount_duffs; @@ -894,11 +888,12 @@ impl WalletSendScreen { )); } - // Parse destination platform address - let address_str = self.destination_address.trim(); - let destination = PlatformAddress::from_bech32m_string(address_str) - .map(|(addr, _)| addr) - .map_err(|e| format!("Invalid platform address: {}", e))?; + // Extract validated platform address + let destination = self + .validated_destination + .as_ref() + .and_then(|v| v.as_platform().copied()) + .ok_or_else(|| "Invalid platform address".to_string())?; // Allocate addresses using the helper function let allocation = allocate_platform_addresses( @@ -990,7 +985,6 @@ impl WalletSendScreen { &mut self, seed_hash: WalletSeedHash, addresses: Vec<(PlatformAddress, Address, u64)>, - network: dash_sdk::dpp::dashcore::Network, ) -> Result { // Amount in credits let amount_credits = self @@ -1020,14 +1014,12 @@ impl WalletSendScreen { )); } - // Parse destination Core address - let address_str = self.destination_address.trim(); - let dest_address: Address = address_str - .parse() - .map_err(|e| format!("Invalid Core address: {}", e))?; - let dest_address = dest_address - .require_network(network) - .map_err(|e| format!("Address network mismatch: {}", e))?; + // Extract validated Core address + let dest_address = self + .validated_destination + .as_ref() + .and_then(|v| v.as_core()) + .ok_or_else(|| "Invalid Core address".to_string())?; let output_script = CoreScript::new(dest_address.script_pubkey()); @@ -1277,7 +1269,7 @@ impl WalletSendScreen { .ok_or_else(|| "Amount is required".to_string())? .value(); - let recipient = self.destination_address.trim().to_string(); + let recipient = self.destination_address_string(); let recipient_bytes = if let Ok((addr, _)) = dash_sdk::dpp::address_funds::OrchardAddress::from_bech32m_string(&recipient) { @@ -1309,9 +1301,11 @@ impl WalletSendScreen { .ok_or_else(|| "Amount is required".to_string())? .value(); - let address_str = self.destination_address.trim(); - let (platform_addr, _) = PlatformAddress::from_bech32m_string(address_str) - .map_err(|e| format!("Invalid platform address: {e}"))?; + let platform_addr = self + .validated_destination + .as_ref() + .and_then(|v| v.as_platform().copied()) + .ok_or_else(|| "Invalid platform address".to_string())?; self.send_status = SendStatus::WaitingForResult; Ok(AppAction::BackendTask( @@ -1359,6 +1353,8 @@ impl WalletSendScreen { let mut selected = is_core_selected; if ui.radio_value(&mut selected, true, "").changed() && selected { self.selected_source = Some(SourceSelection::CoreWallet); + self.address_input = None; + self.validated_destination = None; } ui.label( RichText::new("Core Wallet") @@ -1415,6 +1411,8 @@ impl WalletSendScreen { .collect(); self.selected_source = Some(SourceSelection::PlatformAddresses(addresses_with_balances)); + self.address_input = None; + self.validated_destination = None; } ui.label( RichText::new("Platform Addresses") @@ -1461,6 +1459,8 @@ impl WalletSendScreen { if ui.radio_value(&mut selected, true, "").changed() && selected { self.selected_source = Some(SourceSelection::Shielded(seed_hash, balance)); + self.address_input = None; + self.validated_destination = None; } ui.label( RichText::new("Shielded Balance") @@ -1480,57 +1480,39 @@ impl WalletSendScreen { } fn render_destination_input(&mut self, ui: &mut Ui) { - let dark_mode = ui.ctx().style().visuals.dark_mode; - let dest_type = Self::detect_address_type(&self.destination_address); - - ui.horizontal(|ui| { - ui.label( - RichText::new("Send to") - .color(DashColors::text_primary(dark_mode)) - .strong() - .size(14.0), - ); + let addr_input = self.address_input.get_or_insert_with(|| { + let allowed_kinds = match &self.selected_source { + Some(SourceSelection::CoreWallet) => { + vec![AddressKind::Core, AddressKind::Platform] + } + Some(SourceSelection::PlatformAddresses(_)) => { + vec![AddressKind::Platform, AddressKind::Core] + } + Some(SourceSelection::Shielded(..)) => { + vec![AddressKind::Shielded, AddressKind::Platform] + } + None => AddressKind::ALL.to_vec(), + }; - // Show detected type - if dest_type != AddressType::Unknown { - ui.add_space(10.0); - let (type_text, type_color) = match dest_type { - AddressType::Core => ("Core Address", DashColors::DASH_BLUE), - AddressType::Platform => ("Platform Address", DashColors::PLATFORM_PURPLE), - AddressType::Shielded => ("Shielded Address", Color32::from_rgb(0, 180, 120)), - AddressType::Unknown => ("", Color32::GRAY), - }; - ui.label( - RichText::new(format!("({})", type_text)) - .color(type_color) - .size(12.0), - ); + let mut builder = AddressInput::new(self.app_context.network) + .with_label("Send to") + .with_hint_text("Enter address (X.../y.../dash1.../tdash1...)") + .with_address_kinds(&allowed_kinds); + + // Provide all wallet addresses for autocomplete + if let Ok(wallets_guard) = self.app_context.wallets.read() { + let all_wallets: Vec>> = + wallets_guard.values().cloned().collect(); + if !all_wallets.is_empty() { + builder = builder.with_wallets(&all_wallets); + } } - }); - ui.add_space(8.0); - - Frame::group(ui.style()) - .fill(DashColors::surface(dark_mode)) - .inner_margin(Margin::symmetric(12, 10)) - .corner_radius(5.0) - .show(ui, |ui| { - ui.add( - egui::TextEdit::singleline(&mut self.destination_address) - .hint_text("Enter address (X.../y.../dash1.../tdash1...)") - .desired_width(f32::INFINITY), - ); - }); + builder + }); - // Show error for invalid address - if !self.destination_address.trim().is_empty() && dest_type == AddressType::Unknown { - ui.add_space(5.0); - ui.label( - RichText::new("Invalid address format") - .color(DashColors::ERROR) - .size(12.0), - ); - } + let resp = addr_input.show(ui); + resp.inner.update(&mut self.validated_destination); } fn render_amount_input(&mut self, ui: &mut Ui) { @@ -1554,12 +1536,12 @@ impl WalletSendScreen { .ok() .map(|wallet| wallet.total_balance_duffs() * CREDITS_PER_DUFF) // duffs to credits }); - let dest_type = Self::detect_address_type(&self.destination_address); - let hint = if dest_type == AddressType::Platform { - let destination = - PlatformAddress::from_bech32m_string(self.destination_address.trim()) - .map(|(addr, _)| addr) - .ok(); + let dest_kind = self.destination_kind(); + let hint = if dest_kind == Some(AddressKind::Platform) { + let destination = self + .validated_destination + .as_ref() + .and_then(|v| v.as_platform().copied()); if let Some(destination) = destination { let estimated_fee = estimate_address_funding_fee_from_transition( self.app_context.platform_version(), @@ -1579,11 +1561,11 @@ impl WalletSendScreen { (max, hint) } Some(SourceSelection::PlatformAddresses(addresses)) => { - // Parse destination to exclude it from max calculation (can't send to yourself) - let destination = - PlatformAddress::from_bech32m_string(self.destination_address.trim()) - .map(|(addr, _)| addr) - .ok(); + // Extract destination to exclude it from max calculation (can't send to yourself) + let destination = self + .validated_destination + .as_ref() + .and_then(|v| v.as_platform().copied()); // Filter out destination and sort by balance descending let mut sorted_addresses: Vec<_> = addresses @@ -1623,14 +1605,14 @@ impl WalletSendScreen { None => (None, None), }; - let input_type = match self.selected_source { - Some(SourceSelection::CoreWallet) => AddressType::Core, - Some(SourceSelection::PlatformAddresses(_)) => AddressType::Platform, - Some(SourceSelection::Shielded(_, _)) => AddressType::Shielded, - None => AddressType::Unknown, + let input_kind = match self.selected_source { + Some(SourceSelection::CoreWallet) => Some(AddressKind::Core), + Some(SourceSelection::PlatformAddresses(_)) => Some(AddressKind::Platform), + Some(SourceSelection::Shielded(_, _)) => Some(AddressKind::Shielded), + None => None, }; - let output_type = Self::detect_address_type(&self.destination_address); - let min_amount = self.min_output_amount(input_type, output_type); + let output_kind = self.destination_kind(); + let min_amount = self.min_output_amount(input_kind, output_kind); Frame::group(ui.style()) .fill(DashColors::surface(dark_mode)) @@ -1663,7 +1645,7 @@ impl WalletSendScreen { // Show transaction type hint let tx_type = self.get_transaction_type_description(); - if tx_type != "Send" && !self.destination_address.trim().is_empty() { + if tx_type != "Send" && self.validated_destination.is_some() { ui.add_space(5.0); ui.label( RichText::new(format!("Transaction type: {}", tx_type)) @@ -1674,9 +1656,9 @@ impl WalletSendScreen { } // Show subtract fee checkbox for Core wallet to Core address transactions - let dest_type = Self::detect_address_type(&self.destination_address); + let dest_kind = self.destination_kind(); if matches!(self.selected_source, Some(SourceSelection::CoreWallet)) - && dest_type == AddressType::Core + && dest_kind == Some(AddressKind::Core) { ui.add_space(8.0); ui.horizontal(|ui| { @@ -1711,10 +1693,11 @@ impl WalletSendScreen { _ => return, }; - // Parse destination platform address (if valid) to exclude it from inputs - let destination = PlatformAddress::from_bech32m_string(self.destination_address.trim()) - .map(|(addr, _)| addr) - .ok(); + // Extract destination platform address (if valid) to exclude it from inputs + let destination = self + .validated_destination + .as_ref() + .and_then(|v| v.as_platform().copied()); // Use the same allocation algorithm as the send logic, filtering out the destination let allocation = allocate_platform_addresses( @@ -1808,8 +1791,7 @@ impl WalletSendScreen { .as_ref() .is_some_and(|w| w.read().map(|g| g.is_open()).unwrap_or(false)); - let dest_type = Self::detect_address_type(&self.destination_address); - let has_destination = dest_type != AddressType::Unknown; + let has_destination = self.validated_destination.is_some(); let has_amount = self.amount.as_ref().map(|a| a.value() > 0).unwrap_or(false); let has_source = self.selected_source.is_some(); @@ -1975,10 +1957,10 @@ impl WalletSendScreen { // ========== FEE STRATEGY SECTION ========== // Only show for platform source or platform outputs - let has_platform_output = self.advanced_outputs.iter().any(|o| { - let addr_type = Self::detect_address_type(&o.address); - addr_type == AddressType::Platform - }); + let has_platform_output = self + .advanced_outputs + .iter() + .any(|o| self.detect_address_kind(&o.address) == Some(AddressKind::Platform)); if self.advanced_source_type == AdvancedSourceType::Platform || has_platform_output { ui.label( @@ -2284,14 +2266,14 @@ impl WalletSendScreen { let mut outputs_to_remove = Vec::new(); let num_outputs = self.advanced_outputs.len(); - // Pre-compute address types to avoid borrow issues - let addr_types: Vec = self + // Pre-compute address kinds to avoid borrow issues + let addr_kinds: Vec> = self .advanced_outputs .iter() - .map(|o| Self::detect_address_type(&o.address)) + .map(|o| self.detect_address_kind(&o.address)) .collect(); - for (idx, &addr_type) in addr_types.iter().enumerate() { + for (idx, &addr_kind) in addr_kinds.iter().enumerate() { Frame::group(ui.style()) .fill(DashColors::surface(dark_mode)) .inner_margin(Margin::symmetric(12, 10)) @@ -2307,16 +2289,18 @@ impl WalletSendScreen { ); // Show detected type - if addr_type != AddressType::Unknown { - let (type_text, type_color) = match addr_type { - AddressType::Core => ("Core", DashColors::DASH_BLUE), - AddressType::Platform => { + if let Some(kind) = addr_kind { + let (type_text, type_color) = match kind { + AddressKind::Core => ("Core", DashColors::DASH_BLUE), + AddressKind::Platform => { ("Platform", DashColors::PLATFORM_PURPLE) } - AddressType::Shielded => { + AddressKind::Shielded => { ("Shielded", Color32::from_rgb(0, 180, 120)) } - AddressType::Unknown => ("", Color32::GRAY), + AddressKind::Identity => { + ("Identity", DashColors::PLATFORM_PURPLE) + } }; ui.label( RichText::new(format!("({})", type_text)) @@ -2453,15 +2437,15 @@ impl WalletSendScreen { return Err("Please add at least one output".to_string()); } - // Determine output types - let output_types: Vec = self + // Determine output kinds + let output_kinds: Vec> = self .advanced_outputs .iter() - .map(|o| Self::detect_address_type(&o.address)) + .map(|o| self.detect_address_kind(&o.address)) .collect(); - let has_core_output = output_types.contains(&AddressType::Core); - let has_platform_output = output_types.contains(&AddressType::Platform); + let has_core_output = output_kinds.contains(&Some(AddressKind::Core)); + let has_platform_output = output_kinds.contains(&Some(AddressKind::Platform)); // Validate that we don't mix output types if has_core_output && has_platform_output { diff --git a/src/ui/wallets/unshield_credits_screen.rs b/src/ui/wallets/unshield_credits_screen.rs index 749b320ad..8c244ebe8 100644 --- a/src/ui/wallets/unshield_credits_screen.rs +++ b/src/ui/wallets/unshield_credits_screen.rs @@ -2,17 +2,18 @@ use crate::app::AppAction; use crate::backend_task::shielded::ShieldedTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; +use crate::model::address::{AddressKind, ValidatedAddress}; use crate::model::wallet::WalletSeedHash; +use crate::ui::components::ComponentResponse; +use crate::ui::components::address_input::AddressInput; +use crate::ui::components::component_trait::Component; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; use crate::ui::{MessageType, RootScreenType, ScreenLike}; -use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; -use dash_sdk::dpp::dashcore::Address; use eframe::egui::{self, Context}; use egui::{Color32, RichText}; -use std::str::FromStr; use std::sync::Arc; #[derive(PartialEq)] @@ -22,19 +23,12 @@ enum Status { Complete, } -/// Which kind of destination was parsed from the address input. -enum Destination { - /// Shielded pool → platform address (Type 17 Unshield) - Platform(PlatformAddress), - /// Shielded pool → core L1 address (Type 19 ShieldedWithdrawal) - Core(Address), -} - pub struct UnshieldCreditsScreen { pub app_context: Arc, pub seed_hash: WalletSeedHash, amount_str: String, - address_str: String, + address_input: Option, + validated_destination: Option, max_balance: u64, status: Status, error_message: Option, @@ -55,7 +49,8 @@ impl UnshieldCreditsScreen { app_context: app_context.clone(), seed_hash, amount_str: String::new(), - address_str: String::new(), + address_input: None, + validated_destination: None, max_balance, status: Status::NotStarted, error_message: None, @@ -82,30 +77,6 @@ impl UnshieldCreditsScreen { Some(credits) } } - - /// Parse the address field into a Destination. - /// - /// Tries platform address (Bech32m tdash1.../dash1...) first, then falls - /// back to a core address (Base58 P2PKH/P2SH). - fn parse_destination(&self) -> Option { - let s = self.address_str.trim(); - if s.is_empty() { - return None; - } - - // Try platform address first - if let Ok((pa, _network)) = PlatformAddress::from_bech32m_string(s) { - return Some(Destination::Platform(pa)); - } - - // Try core address - if let Ok(addr) = Address::from_str(s) { - let addr = addr.require_network(self.app_context.network).ok()?; - return Some(Destination::Core(addr)); - } - - None - } } impl ScreenLike for UnshieldCreditsScreen { @@ -155,33 +126,40 @@ impl ScreenLike for UnshieldCreditsScreen { return; } - // Destination address input - ui.horizontal(|ui| { - ui.label("To address:"); - ui.text_edit_singleline(&mut self.address_str); + // Destination address input via AddressInput component + let addr_input = self.address_input.get_or_insert_with(|| { + let mut builder = AddressInput::new(self.app_context.network) + .with_address_kinds(&[AddressKind::Core, AddressKind::Platform]) + .with_label("To address") + .with_hint_text( + "Enter a platform address (tdash1.../dash1...) or core DASH address", + ); + + if let Ok(wallets) = self.app_context.wallets.read() { + let all_wallets: Vec<_> = wallets.values().cloned().collect(); + builder = builder.with_wallets(&all_wallets); + } + + builder }); + let resp = addr_input.show(ui); + resp.inner.update(&mut self.validated_destination); // Show what was parsed - match self.parse_destination() { - Some(Destination::Platform(_)) => { + match self.validated_destination.as_ref().map(|v| v.kind()) { + Some(AddressKind::Platform) => { ui.colored_label( Color32::DARK_GREEN, - "Platform address — will unshield to platform (Type 17)", + "Platform address — credits will be moved to this platform address", ); } - Some(Destination::Core(_)) => { + Some(AddressKind::Core) => { ui.colored_label( Color32::DARK_GREEN, - "Core address — will withdraw to core DASH (Type 19)", - ); - } - None if !self.address_str.trim().is_empty() => { - ui.colored_label( - Color32::from_rgb(255, 100, 100), - "Unrecognised address — enter a platform address (tdash1…/dash1…) or a core DASH address", + "Core address — credits will be withdrawn as DASH to this address", ); } - None => {} + _ => {} } ui.add_space(10.0); @@ -202,9 +180,8 @@ impl ScreenLike for UnshieldCreditsScreen { let amount_ok = self .parse_amount_credits() .is_some_and(|a| a <= self.max_balance); - let destination = self.parse_destination(); - let can_confirm = - self.status == Status::NotStarted && amount_ok && destination.is_some(); + let has_destination = self.validated_destination.is_some(); + let can_confirm = self.status == Status::NotStarted && amount_ok && has_destination; if self.status == Status::WaitingForResult { ui.horizontal(|ui| { @@ -213,8 +190,8 @@ impl ScreenLike for UnshieldCreditsScreen { }); } else { ui.horizontal(|ui| { - let btn_label = match &destination { - Some(Destination::Core(_)) => "Withdraw to Core", + let btn_label = match self.validated_destination.as_ref().map(|v| v.kind()) { + Some(AddressKind::Core) => "Withdraw to Core", _ => "Unshield", }; @@ -229,30 +206,30 @@ impl ScreenLike for UnshieldCreditsScreen { .clicked() && let Some(amount) = self.parse_amount_credits() { - match self.parse_destination() { - Some(Destination::Platform(addr)) => { + match &self.validated_destination { + Some(ValidatedAddress::Platform { address: addr, .. }) => { self.status = Status::WaitingForResult; self.error_message = None; action = AppAction::BackendTask(BackendTask::ShieldedTask( ShieldedTask::UnshieldCredits { seed_hash: self.seed_hash, amount, - to_platform_address: addr, + to_platform_address: *addr, }, )); } - Some(Destination::Core(addr)) => { + Some(ValidatedAddress::Core(addr)) => { self.status = Status::WaitingForResult; self.error_message = None; action = AppAction::BackendTask(BackendTask::ShieldedTask( ShieldedTask::ShieldedWithdrawal { seed_hash: self.seed_hash, amount, - to_core_address: addr, + to_core_address: addr.clone(), }, )); } - None => {} + _ => {} } } diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 9e208d942..8305f6e8c 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -1655,10 +1655,21 @@ impl WalletsBalancesScreen { format!("Addresses ({})", category.label(*index)) }) .unwrap_or_else(|| "Addresses".to_string()); - ui.heading( - RichText::new(addresses_heading) - .color(DashColors::text_primary(dark_mode)), - ); + ui.horizontal(|ui| { + ui.heading( + RichText::new(addresses_heading) + .color(DashColors::text_primary(dark_mode)), + ); + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + ui.checkbox( + &mut self.show_zero_balance_addresses, + "Show zero-balance addresses", + ); + }, + ); + }); ui.add_space(8.0); action |= self.render_address_table(ui); From 6e2b2b0c419b4dd27f8ac687532c41ac69971097 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 11:25:11 +0100 Subject: [PATCH 051/147] fix(error): handle asset lock and shielded insufficient funds errors with user-friendly messages Add two new TaskError variants to replace raw SDK errors with actionable, jargon-free messages: - AssetLockOutPointInsufficientBalance: shown when an asset lock outpoint has been partially consumed and lacks credits for the operation. Extracts credits_left/credits_required and displays DASH amounts. - ShieldedAddressInsufficientFunds: shown when a shielded broadcast fails because the address balance is too low. Detected in shielded_broadcast_error() before falling through to ShieldedBroadcastFailed. Both follow the IdentityInsufficientBalance pattern with format_credits_as_dash. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend_task/error.rs | 197 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index f3b12da7d..67ee9b4f1 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -332,6 +332,21 @@ pub enum TaskError { source_error: Box, }, + /// The asset lock transaction outpoint does not have enough remaining balance. + #[error( + "Not enough funds in this transaction to complete the operation. \ + Available: {available_dash}, required: {required_dash}. \ + Try using a different funding source or top up first.", + available_dash = format_credits_as_dash(*.available), + required_dash = format_credits_as_dash(*.required) + )] + AssetLockOutPointInsufficientBalance { + available: u64, + required: u64, + #[source] + source_error: Box, + }, + /// Fetching address information from the platform failed. #[error("Could not retrieve address information from the platform. Please retry.")] PlatformFetchError { @@ -685,6 +700,21 @@ pub enum TaskError { source: Box, }, + /// The address used for a shielded transaction does not have enough locked funds. + #[error( + "Not enough funds locked for this shielded transaction. \ + Available: {available_dash}, required: {required_dash}. \ + Try locking more funds first.", + available_dash = format_credits_as_dash(*.available), + required_dash = format_credits_as_dash(*.required) + )] + ShieldedAddressInsufficientFunds { + available: u64, + required: u64, + #[source] + source_error: Box, + }, + /// The shielded pool does not have enough notes for an outgoing transaction. #[error( "This type of transaction is not available right now because the network needs more activity. Please try again later." @@ -837,6 +867,15 @@ pub fn shielded_broadcast_error(e: SdkError) -> TaskError { source_error: Box::new(e), }; } + if let Some(ConsensusError::StateError(StateError::AddressNotEnoughFundsError(addr_err))) = + consensus_error + { + return TaskError::ShieldedAddressInsufficientFunds { + available: addr_err.balance(), + required: addr_err.required_balance(), + source_error: Box::new(e), + }; + } TaskError::ShieldedBroadcastFailed { source: Box::new(e), } @@ -948,6 +987,10 @@ impl From for TaskError { available: u64, required: u64, }, + AssetLockOutPointInsufficientBalance { + available: u64, + required: u64, + }, InsufficientPoolNotes { current_count: u64, minimum_required: u64, @@ -985,6 +1028,12 @@ impl From for TaskError { ConsensusError::BasicError( BasicError::InvalidInstantAssetLockProofSignatureError(_), ) => Some(ConsensusKind::InvalidInstantLockProof), + ConsensusError::BasicError( + BasicError::IdentityAssetLockTransactionOutPointNotEnoughBalanceError(e), + ) => Some(ConsensusKind::AssetLockOutPointInsufficientBalance { + available: e.credits_left(), + required: e.credits_required(), + }), ConsensusError::StateError(StateError::InsufficientPoolNotesError(e)) => { Some(ConsensusKind::InsufficientPoolNotes { current_count: e.current_count(), @@ -1033,6 +1082,14 @@ impl From for TaskError { required, source_error: boxed, }, + Some(ConsensusKind::AssetLockOutPointInsufficientBalance { + available, + required, + }) => TaskError::AssetLockOutPointInsufficientBalance { + available, + required, + source_error: boxed, + }, Some(ConsensusKind::InsufficientPoolNotes { current_count, minimum_required, @@ -1598,4 +1655,144 @@ mod tests { "Expected no ZK jargon in user message, got: {msg}" ); } + + #[test] + fn from_sdk_error_asset_lock_outpoint_insufficient_balance_via_consensus() { + use dash_sdk::dpp::consensus::basic::identity::IdentityAssetLockTransactionOutPointNotEnoughBalanceError; + use dashcore::hashes::Hash; + let consensus = ConsensusError::from( + IdentityAssetLockTransactionOutPointNotEnoughBalanceError::new( + dashcore::Txid::from_byte_array([0u8; 32]), + 0, + 100_000_000, + 100_000_000, + 241_000_000, + ), + ); + let sdk_err = SdkError::from(consensus); + let err = TaskError::from(sdk_err); + assert!( + matches!( + err, + TaskError::AssetLockOutPointInsufficientBalance { + available: 100_000_000, + required: 241_000_000, + .. + } + ), + "Expected AssetLockOutPointInsufficientBalance, got: {err:?}" + ); + } + + #[test] + fn from_sdk_error_asset_lock_outpoint_insufficient_balance_via_broadcast() { + use dash_sdk::dpp::consensus::basic::identity::IdentityAssetLockTransactionOutPointNotEnoughBalanceError; + use dashcore::hashes::Hash; + let consensus = ConsensusError::from( + IdentityAssetLockTransactionOutPointNotEnoughBalanceError::new( + dashcore::Txid::from_byte_array([0u8; 32]), + 0, + 500_000_000, + 200_000_000, + 400_000_000, + ), + ); + let broadcast_err = dash_sdk::error::StateTransitionBroadcastError { + code: 40100, + message: "not enough balance".to_string(), + cause: Some(consensus), + }; + let sdk_err = SdkError::StateTransitionBroadcastError(broadcast_err); + let err = TaskError::from(sdk_err); + assert!( + matches!( + err, + TaskError::AssetLockOutPointInsufficientBalance { + available: 200_000_000, + required: 400_000_000, + .. + } + ), + "Expected AssetLockOutPointInsufficientBalance, got: {err:?}" + ); + } + + #[test] + fn asset_lock_outpoint_insufficient_balance_display_includes_amounts() { + use dash_sdk::dpp::consensus::basic::identity::IdentityAssetLockTransactionOutPointNotEnoughBalanceError; + use dashcore::hashes::Hash; + let consensus = ConsensusError::from( + IdentityAssetLockTransactionOutPointNotEnoughBalanceError::new( + dashcore::Txid::from_byte_array([0u8; 32]), + 0, + 100_000_000_000, + 100_000_000_000, + 241_000_000_000, + ), + ); + let sdk_err = SdkError::from(consensus); + let err = TaskError::from(sdk_err); + let msg = err.to_string(); + assert!( + msg.contains("DASH"), + "Expected DASH amounts in message, got: {msg}" + ); + assert!( + msg.contains("funding source"), + "Expected actionable guidance in message, got: {msg}" + ); + } + + #[test] + fn shielded_broadcast_error_detects_address_not_enough_funds() { + use dash_sdk::dpp::address_funds::PlatformAddress; + use dash_sdk::dpp::consensus::state::address_funds::AddressNotEnoughFundsError; + let address = PlatformAddress::P2pkh([0u8; 20]); + let consensus = ConsensusError::from(AddressNotEnoughFundsError::new( + address, + 63_766_741_300, + 100_000_000_000, + )); + let broadcast_err = dash_sdk::error::StateTransitionBroadcastError { + code: 40300, + message: "address not enough funds".to_string(), + cause: Some(consensus), + }; + let sdk_err = SdkError::StateTransitionBroadcastError(broadcast_err); + let err = shielded_broadcast_error(sdk_err); + assert!( + matches!( + err, + TaskError::ShieldedAddressInsufficientFunds { + available: 63_766_741_300, + required: 100_000_000_000, + .. + } + ), + "Expected ShieldedAddressInsufficientFunds, got: {err:?}" + ); + } + + #[test] + fn shielded_address_insufficient_funds_display_includes_amounts() { + use dash_sdk::dpp::address_funds::PlatformAddress; + use dash_sdk::dpp::consensus::state::address_funds::AddressNotEnoughFundsError; + let address = PlatformAddress::P2pkh([0u8; 20]); + let consensus = ConsensusError::from(AddressNotEnoughFundsError::new( + address, + 63_766_741_300, + 100_000_000_000, + )); + let sdk_err = SdkError::from(consensus); + let err = shielded_broadcast_error(sdk_err); + let msg = err.to_string(); + assert!( + msg.contains("DASH"), + "Expected DASH amounts in message, got: {msg}" + ); + assert!( + msg.contains("locking more funds"), + "Expected actionable guidance in message, got: {msg}" + ); + } } From 3e744c82cae064f78616489421757e82f3c582ea Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 11:19:16 +0100 Subject: [PATCH 052/147] fix(ui): use AmountInput component for all amount inputs in wallet screens Replace raw text_edit_singleline + custom parse_amount_*() methods with the AmountInput component in 4 shielded screens (unshield, shield, shield-from-asset-lock, shielded-send). This fixes a bug where entering "1" would send 1 credit instead of 1 DASH because the raw input had ambiguous parsing. Also adds "Amount (DASH):" label to SendScreen's AmountInput and removes its redundant manual label. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/send_screen.rs | 10 +-- src/ui/wallets/shield_credits_screen.rs | 43 +++++++------ .../wallets/shield_from_asset_lock_screen.rs | 61 +++++++------------ src/ui/wallets/shielded_send_screen.rs | 59 +++++++----------- src/ui/wallets/unshield_credits_screen.rs | 53 ++++++---------- 5 files changed, 84 insertions(+), 142 deletions(-) diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index fa029898d..102f2fad2 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -1519,15 +1519,6 @@ impl WalletSendScreen { let dark_mode = ui.ctx().style().visuals.dark_mode; let fee_estimator = self.app_context.fee_estimator(); - ui.label( - RichText::new("Amount") - .color(DashColors::text_primary(dark_mode)) - .strong() - .size(14.0), - ); - - ui.add_space(8.0); - // Get max amount and hint based on source selection let (max_amount_credits, max_hint) = match &self.selected_source { Some(SourceSelection::CoreWallet) => { @@ -1621,6 +1612,7 @@ impl WalletSendScreen { .show(ui, |ui| { let amount_input = self.amount_input.get_or_insert_with(|| { AmountInput::new(Amount::new_dash(0.0)) + .with_label("Amount (DASH):") .with_hint_text("Enter amount") .with_max_button(true) .with_desired_width(150.0) diff --git a/src/ui/wallets/shield_credits_screen.rs b/src/ui/wallets/shield_credits_screen.rs index 7519dd3e7..135474d0a 100644 --- a/src/ui/wallets/shield_credits_screen.rs +++ b/src/ui/wallets/shield_credits_screen.rs @@ -3,7 +3,11 @@ use crate::backend_task::shielded::ShieldedTask; use crate::backend_task::shielded::bundle::ShieldStage; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; +use crate::model::amount::Amount; use crate::model::wallet::WalletSeedHash; +use crate::ui::components::ComponentResponse; +use crate::ui::components::amount_input::AmountInput; +use crate::ui::components::component_trait::Component; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; @@ -28,7 +32,8 @@ enum Status { pub struct ShieldCreditsScreen { pub app_context: Arc, pub seed_hash: WalletSeedHash, - amount_str: String, + amount_input: Option, + amount: Option, from_address: Option, status: Status, error_message: Option, @@ -66,7 +71,8 @@ impl ShieldCreditsScreen { Self { app_context: app_context.clone(), seed_hash, - amount_str: String::new(), + amount_input: None, + amount: None, from_address, status: Status::NotStarted, error_message: None, @@ -83,18 +89,6 @@ impl ShieldCreditsScreen { } } - fn parse_amount_credits(&self) -> Option { - let trimmed = self.amount_str.trim(); - if trimmed.is_empty() { - return None; - } - let dash: f64 = trimmed.parse().ok()?; - if dash <= 0.0 { - return None; - } - Some((dash * CREDITS_PER_DUFF as f64 * 1e8) as u64) - } - fn parse_repeat_count(&self) -> u32 { self.repeat_count_str .trim() @@ -159,7 +153,8 @@ impl ShieldCreditsScreen { /// Queue the next sequential batch task if any remain. fn queue_next_sequential(&mut self) { if self.batch_remaining > 0 - && let (Some(amount), Some(addr)) = (self.parse_amount_credits(), self.from_address) + && let (Some(amount), Some(addr)) = + (self.amount.as_ref().map(|a| a.value()), self.from_address) { self.batch_remaining -= 1; self.pending_next_task = Some(self.make_shield_task(amount, addr, None)); @@ -412,10 +407,18 @@ impl ScreenLike for ShieldCreditsScreen { } // Amount input - ui.horizontal(|ui| { - ui.label("Amount (DASH):"); - ui.text_edit_singleline(&mut self.amount_str); + let balance_credits = self.read_address_balance(); + let amount_input = self.amount_input.get_or_insert_with(|| { + AmountInput::new(Amount::new_dash(0.0)) + .with_label("Amount (DASH):") + .with_hint_text("Enter amount") + .with_desired_width(150.0) }); + if let Some(balance_credits) = balance_credits { + amount_input.set_max_amount(Some(balance_credits)); + } + let response = amount_input.show(ui); + response.inner.update(&mut self.amount); ui.add_space(5.0); // Dev-mode batch controls @@ -591,7 +594,7 @@ impl ScreenLike for ShieldCreditsScreen { // Buttons (only when not busy) if !is_busy && self.status == Status::NotStarted { - let can_confirm = self.parse_amount_credits().is_some(); + let can_confirm = self.amount.as_ref().map(|a| a.value()).is_some(); ui.horizontal(|ui| { if ui @@ -604,7 +607,7 @@ impl ScreenLike for ShieldCreditsScreen { ) .clicked() && let (Some(amount), Some(addr)) = - (self.parse_amount_credits(), self.from_address) + (self.amount.as_ref().map(|a| a.value()), self.from_address) { self.error_message = None; let repeat = if self.app_context.is_developer_mode() { diff --git a/src/ui/wallets/shield_from_asset_lock_screen.rs b/src/ui/wallets/shield_from_asset_lock_screen.rs index 36e275ae0..6ff9931f1 100644 --- a/src/ui/wallets/shield_from_asset_lock_screen.rs +++ b/src/ui/wallets/shield_from_asset_lock_screen.rs @@ -2,7 +2,11 @@ use crate::app::AppAction; use crate::backend_task::shielded::ShieldedTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; +use crate::model::amount::Amount; use crate::model::wallet::WalletSeedHash; +use crate::ui::components::ComponentResponse; +use crate::ui::components::amount_input::AmountInput; +use crate::ui::components::component_trait::Component; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; @@ -22,7 +26,8 @@ enum Status { pub struct ShieldFromAssetLockScreen { pub app_context: Arc, pub seed_hash: WalletSeedHash, - amount_str: String, + amount_input: Option, + amount: Option, core_balance_duffs: u64, status: Status, error_message: Option, @@ -45,30 +50,14 @@ impl ShieldFromAssetLockScreen { Self { app_context: app_context.clone(), seed_hash, - amount_str: String::new(), + amount_input: None, + amount: None, core_balance_duffs, status: Status::NotStarted, error_message: None, success_message: None, } } - - /// Parse amount input as DASH (decimal) and return duffs. - fn parse_amount_duffs(&self) -> Option { - let trimmed = self.amount_str.trim(); - if trimmed.is_empty() { - return None; - } - let dash: f64 = trimmed.parse().ok()?; - if dash <= 0.0 { - return None; - } - let duffs = (dash * 1e8) as u64; - if duffs == 0 { - return None; - } - Some(duffs) - } } impl ScreenLike for ShieldFromAssetLockScreen { @@ -117,30 +106,21 @@ impl ScreenLike for ShieldFromAssetLockScreen { } // Amount input - ui.horizontal(|ui| { - ui.label("Amount (DASH):"); - ui.text_edit_singleline(&mut self.amount_str); + let max_credits = self.core_balance_duffs * CREDITS_PER_DUFF; + let amount_input = self.amount_input.get_or_insert_with(|| { + AmountInput::new(Amount::new_dash(0.0)) + .with_label("Amount (DASH):") + .with_hint_text("Enter amount") + .with_max_button(true) + .with_desired_width(150.0) }); - if let Some(duffs) = self.parse_amount_duffs() { - let credits = duffs * CREDITS_PER_DUFF; - let dash = duffs as f64 / 1e8; - ui.label(format!( - "= {:.8} DASH = {} credits on platform", - dash, credits - )); - if duffs > self.core_balance_duffs { - ui.colored_label( - Color32::from_rgb(255, 100, 100), - "Exceeds core wallet balance", - ); - } - } + amount_input.set_max_amount(Some(max_credits)); + let response = amount_input.show(ui); + response.inner.update(&mut self.amount); ui.add_space(15.0); // Confirm - let amount_ok = self - .parse_amount_duffs() - .is_some_and(|a| a <= self.core_balance_duffs); + let amount_ok = self.amount.is_some(); let can_confirm = self.status == Status::NotStarted && amount_ok; if self.status == Status::WaitingForResult { @@ -161,8 +141,9 @@ impl ScreenLike for ShieldFromAssetLockScreen { .fill(crate::ui::theme::DashColors::DASH_BLUE), ) .clicked() - && let Some(amount_duffs) = self.parse_amount_duffs() + && let Some(amount_credits) = self.amount.as_ref().map(|a| a.value()) { + let amount_duffs = amount_credits / CREDITS_PER_DUFF; self.status = Status::WaitingForResult; self.error_message = None; action = AppAction::BackendTask(BackendTask::ShieldedTask( diff --git a/src/ui/wallets/shielded_send_screen.rs b/src/ui/wallets/shielded_send_screen.rs index ae8c26006..a4583251c 100644 --- a/src/ui/wallets/shielded_send_screen.rs +++ b/src/ui/wallets/shielded_send_screen.rs @@ -2,7 +2,11 @@ use crate::app::AppAction; use crate::backend_task::shielded::ShieldedTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; +use crate::model::amount::Amount; use crate::model::wallet::WalletSeedHash; +use crate::ui::components::ComponentResponse; +use crate::ui::components::amount_input::AmountInput; +use crate::ui::components::component_trait::Component; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; @@ -22,7 +26,8 @@ enum Status { pub struct ShieldedSendScreen { pub app_context: Arc, pub seed_hash: WalletSeedHash, - amount_str: String, + amount_input: Option, + amount: Option, recipient_address_input: String, max_balance: u64, status: Status, @@ -43,7 +48,8 @@ impl ShieldedSendScreen { Self { app_context: app_context.clone(), seed_hash, - amount_str: String::new(), + amount_input: None, + amount: None, recipient_address_input: String::new(), max_balance, status: Status::NotStarted, @@ -52,26 +58,6 @@ impl ShieldedSendScreen { } } - fn parse_amount_credits(&self) -> Option { - let trimmed = self.amount_str.trim(); - if trimmed.is_empty() { - return None; - } - if trimmed.contains('.') { - let dash: f64 = trimmed.parse().ok()?; - if dash <= 0.0 { - return None; - } - Some((dash * CREDITS_PER_DUFF as f64 * 1e8) as u64) - } else { - let credits: u64 = trimmed.parse().ok()?; - if credits == 0 { - return None; - } - Some(credits) - } - } - fn validate_recipient(&self) -> Option> { let trimmed = self.recipient_address_input.trim(); if trimmed.is_empty() { @@ -149,23 +135,20 @@ impl ScreenLike for ShieldedSendScreen { ui.add_space(10.0); // Amount input - ui.horizontal(|ui| { - ui.label("Amount (DASH or credits):"); - ui.text_edit_singleline(&mut self.amount_str); + let amount_input = self.amount_input.get_or_insert_with(|| { + AmountInput::new(Amount::new_dash(0.0)) + .with_label("Amount (DASH):") + .with_hint_text("Enter amount") + .with_max_button(true) + .with_desired_width(150.0) }); - if let Some(credits) = self.parse_amount_credits() { - let dash = credits as f64 / CREDITS_PER_DUFF as f64 / 1e8; - ui.label(format!("= {:.8} DASH ({} credits)", dash, credits)); - if credits > self.max_balance { - ui.colored_label(Color32::from_rgb(255, 100, 100), "Exceeds shielded balance"); - } - } + amount_input.set_max_amount(Some(self.max_balance)); + let response = amount_input.show(ui); + response.inner.update(&mut self.amount); ui.add_space(15.0); // Confirm - let amount_ok = self - .parse_amount_credits() - .is_some_and(|a| a <= self.max_balance); + let amount_ok = self.amount.is_some(); let recipient_ok = self.validate_recipient().is_some(); let can_confirm = self.status == Status::NotStarted && amount_ok && recipient_ok; @@ -185,8 +168,10 @@ impl ScreenLike for ShieldedSendScreen { .fill(crate::ui::theme::DashColors::DASH_BLUE), ) .clicked() - && let (Some(amount), Some(recipient_bytes)) = - (self.parse_amount_credits(), self.validate_recipient()) + && let (Some(amount), Some(recipient_bytes)) = ( + self.amount.as_ref().map(|a| a.value()), + self.validate_recipient(), + ) { self.status = Status::WaitingForResult; self.error_message = None; diff --git a/src/ui/wallets/unshield_credits_screen.rs b/src/ui/wallets/unshield_credits_screen.rs index 8c244ebe8..29fb4f463 100644 --- a/src/ui/wallets/unshield_credits_screen.rs +++ b/src/ui/wallets/unshield_credits_screen.rs @@ -3,9 +3,11 @@ use crate::backend_task::shielded::ShieldedTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; use crate::model::address::{AddressKind, ValidatedAddress}; +use crate::model::amount::Amount; use crate::model::wallet::WalletSeedHash; use crate::ui::components::ComponentResponse; use crate::ui::components::address_input::AddressInput; +use crate::ui::components::amount_input::AmountInput; use crate::ui::components::component_trait::Component; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; @@ -26,7 +28,8 @@ enum Status { pub struct UnshieldCreditsScreen { pub app_context: Arc, pub seed_hash: WalletSeedHash, - amount_str: String, + amount_input: Option, + amount: Option, address_input: Option, validated_destination: Option, max_balance: u64, @@ -48,7 +51,8 @@ impl UnshieldCreditsScreen { Self { app_context: app_context.clone(), seed_hash, - amount_str: String::new(), + amount_input: None, + amount: None, address_input: None, validated_destination: None, max_balance, @@ -57,26 +61,6 @@ impl UnshieldCreditsScreen { success_message: None, } } - - fn parse_amount_credits(&self) -> Option { - let trimmed = self.amount_str.trim(); - if trimmed.is_empty() { - return None; - } - if trimmed.contains('.') { - let dash: f64 = trimmed.parse().ok()?; - if dash <= 0.0 { - return None; - } - Some((dash * CREDITS_PER_DUFF as f64 * 1e8) as u64) - } else { - let credits: u64 = trimmed.parse().ok()?; - if credits == 0 { - return None; - } - Some(credits) - } - } } impl ScreenLike for UnshieldCreditsScreen { @@ -164,22 +148,19 @@ impl ScreenLike for UnshieldCreditsScreen { ui.add_space(10.0); // Amount input - ui.horizontal(|ui| { - ui.label("Amount (DASH):"); - ui.text_edit_singleline(&mut self.amount_str); + let amount_input = self.amount_input.get_or_insert_with(|| { + AmountInput::new(Amount::new_dash(0.0)) + .with_label("Amount (DASH):") + .with_hint_text("Enter amount") + .with_max_button(true) + .with_desired_width(150.0) }); - if let Some(credits) = self.parse_amount_credits() { - let dash = credits as f64 / CREDITS_PER_DUFF as f64 / 1e8; - ui.label(format!("= {:.8} DASH ({} credits)", dash, credits)); - if credits > self.max_balance { - ui.colored_label(Color32::from_rgb(255, 100, 100), "Exceeds shielded balance"); - } - } + amount_input.set_max_amount(Some(self.max_balance)); + let response = amount_input.show(ui); + response.inner.update(&mut self.amount); ui.add_space(15.0); - let amount_ok = self - .parse_amount_credits() - .is_some_and(|a| a <= self.max_balance); + let amount_ok = self.amount.is_some(); let has_destination = self.validated_destination.is_some(); let can_confirm = self.status == Status::NotStarted && amount_ok && has_destination; @@ -204,7 +185,7 @@ impl ScreenLike for UnshieldCreditsScreen { .fill(crate::ui::theme::DashColors::DASH_BLUE), ) .clicked() - && let Some(amount) = self.parse_amount_credits() + && let Some(amount) = self.amount.as_ref().map(|a| a.value()) { match &self.validated_destination { Some(ValidatedAddress::Platform { address: addr, .. }) => { From 839f1ee6c13a0727dacd8690f80fe66a71946dbb Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 11:32:53 +0100 Subject: [PATCH 053/147] docs: add UI components reference and teach CLAUDE.md to use it Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 4 ++ src/ui/components/README.md | 83 +++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 src/ui/components/README.md diff --git a/CLAUDE.md b/CLAUDE.md index 251995e2c..5726ca6a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -196,6 +196,10 @@ response.inner.update(&mut self.amount); **Anti-patterns:** public mutable fields, eager initialization, not clearing invalid data +### UI Components Catalog + +See `src/ui/components/README.md` for a complete reference of available components, their APIs, and usage patterns. **Always consult this file before creating new UI elements** to avoid duplicating existing components. + ## Message Display User-facing messages (errors, warnings, success, infos) use `MessageBanner` (`src/ui/components/message_banner.rs`). Global banners are rendered centrally by `island_central_panel()` — `AppState::update()` sets them automatically for backend task results. When using `MessageBanner::set_global()`, no guard is needed — it is idempotent and automatically logs at the appropriate level (error/warn/debug). Screens only override `display_message()` for side-effects. See the component's doc comments and `docs/ai-design/2026-02-17-unified-messages/` for details. diff --git a/src/ui/components/README.md b/src/ui/components/README.md new file mode 100644 index 000000000..f93476055 --- /dev/null +++ b/src/ui/components/README.md @@ -0,0 +1,83 @@ +# UI Components Reference + +Concise catalog of all reusable UI components. Consult before creating new UI elements. + +## Core Traits (`component_trait.rs`) + +| Trait | Methods | +|-------|---------| +| `Component` | `show(&mut self, ui) -> InnerResponse`, `current_value() -> Option` | +| `ComponentResponse` | `has_changed()`, `is_valid()`, `changed_value()`, `error_message()`, `update(&mut Option)` | + +## Input Components + +| Component | File | DomainType | Description | +|-----------|------|------------|-------------| +| `AmountInput` | `amount_input.rs` | `Amount` | Decimal amount with validation, min/max, Max button, unit name | +| `AddressInput` | `address_input.rs` | `ValidatedAddress` | Unified address with autocomplete, type detection (Core/Platform/Shielded/Identity), DPNS resolution | +| `PasswordInput` | `password_input.rs` | N/A (security) | Masked input with hold-to-reveal, zeroizes on drop. NOT ComponentResponse | +| `IdentitySelector` | `identity_selector.rs` | N/A (Widget) | ComboBox dropdown for identity selection | + +## Dialog Components + +| Component | File | DomainType | Description | +|-----------|------|------------|-------------| +| `ConfirmationDialog` | `confirmation_dialog.rs` | `ConfirmationStatus` | Modal confirm/cancel with danger mode | +| `SelectionDialog` | `selection_dialog.rs` | `SelectionStatus` | Modal with ComboBox selection | +| `InfoPopup` | `info_popup.rs` | N/A | Info popup with optional markdown | +| `WalletUnlockPopup` | `wallet_unlock_popup.rs` | `WalletUnlockResult` | Password-based wallet unlock | + +## Feedback Components + +| Component | File | Description | +|-----------|------|-------------| +| `MessageBanner` | `message_banner.rs` | Global error/warning/success/info banners. `set_global()`, `with_details()`, auto-dismiss. Extensions: `OptionBannerExt`, `OptionBannerShowExt`, `ResultBannerExt` | + +## Styled Components (`styled.rs`) + +| Component | Description | +|-----------|-------------| +| `StyledButton` | Primary/Secondary/Danger/Ghost variants, Small/Medium/Large | +| `StyledCard` | Card with padding and border | +| `StyledCheckbox` | Themed checkbox | +| `GradientButton` | Animated gradient with optional glow | +| `GlassCard` | Glass-morphism card | +| `HeroSection` | Large gradient header | +| `AnimatedIcon` | Configurable animated icon | +| `AnimatedGradientCard` | Card with animated gradient border | + +## Layout + +| Function/Module | File | Description | +|-----------------|------|-------------| +| `island_central_panel()` | `styled.rs` | Responsive central panel, renders global MessageBanners | +| `add_location_view()` | `top_panel.rs` | Breadcrumb navigation + connection status | +| `add_left_panel()` | `left_panel.rs` | Main icon navigation sidebar | +| `add_left_panel()` | `left_wallet_panel.rs` | Wallet/identity sidebar | +| Subscreen panels | `*_subscreen_chooser_panel.rs` | Tab navigation for DPNS, DashPay, Tokens, Tools | +| `ContractChooserState` | `contract_chooser_panel.rs` | Hierarchical contract tree view | + +## Utility + +| Component | File | Description | +|-----------|------|-------------| +| `U256EntropyGrid` | `entropy_grid.rs` | 32x8 interactive grid for 256-bit entropy generation | +| `ScreenWithWalletUnlock` | `wallet_unlock.rs` | Trait for screens needing wallet unlock | + +## Usage Pattern + +```rust +// Lazy init in screen struct +amount_input: Option, +amount: Option, + +// In show(): +let widget = self.amount_input.get_or_insert_with(|| { + AmountInput::new(Amount::new_dash(0.0)) + .with_label("Amount (DASH):") + .with_hint_text("Enter amount") + .with_max_button(true) +}); +let response = widget.show(ui); +response.inner.update(&mut self.amount); +``` From 3e2fac6e68299c81bd056c4b9c4aabe19471bd71 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:07:54 +0100 Subject: [PATCH 054/147] feat(ui): redesign wallet screen information architecture Replace the old Balances|Shielded top-level tabs and account dropdown with a unified layout that better matches how users think about their wallet: - Balance section with collapsible breakdown (Core/Platform split) - Dev Tools expandable button replaces scattered dev-mode controls - Transaction History shown as collapsible section (visible in all modes) - Accounts & Addresses use tabs instead of a dropdown selector - Shielded view moved from top-level tab to an account tab - Asset Locks restricted to the Dash Core tab only - "Main Account" renamed to "Dash Core" throughout - Fee column added to transaction table (dev mode only) - "Get Test Dash" button opens testnet faucet in dev tools Tab visibility follows progressive disclosure: default mode shows only Dash Core, Platform, and Shielded tabs. Developer mode reveals all account types that have addresses. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/account_summary.rs | 41 +- src/ui/wallets/wallets_screen/mod.rs | 595 +++++++++++++++------------ 2 files changed, 379 insertions(+), 257 deletions(-) diff --git a/src/ui/wallets/account_summary.rs b/src/ui/wallets/account_summary.rs index 7dbd44864..a5008a9e2 100644 --- a/src/ui/wallets/account_summary.rs +++ b/src/ui/wallets/account_summary.rs @@ -54,7 +54,7 @@ impl AccountCategory { pub fn label(&self, index: Option) -> String { match self { AccountCategory::Bip44 => match index { - Some(0) => "Main Account".to_string(), + Some(0) => "Dash Core".to_string(), Some(idx) => format!("BIP44 Account #{}", idx), None => "BIP44 Account".to_string(), }, @@ -71,7 +71,7 @@ impl AccountCategory { AccountCategory::ProviderOwner => "Provider Owner".to_string(), AccountCategory::ProviderOperator => "Provider Operator".to_string(), AccountCategory::ProviderPlatform => "Provider Platform".to_string(), - AccountCategory::PlatformPayment => "Platform Account".to_string(), + AccountCategory::PlatformPayment => "Platform".to_string(), AccountCategory::Other(reference) => format!("{:?}", reference), } } @@ -136,9 +136,39 @@ impl AccountCategory { } } + /// Returns a short label suitable for tab headers. + pub fn tab_label(&self, index: Option) -> &'static str { + match self { + AccountCategory::Bip44 => match index { + Some(0) => "Dash Core", + _ => "BIP44", + }, + AccountCategory::Bip32 => "Legacy BIP32", + AccountCategory::CoinJoin => "CoinJoin", + AccountCategory::IdentityRegistration => "Identity Registration", + AccountCategory::IdentitySystem => "Identity System", + AccountCategory::IdentityTopup => "Identity Top-up", + AccountCategory::IdentityInvitation => "Identity Invitation", + AccountCategory::ProviderVoting + | AccountCategory::ProviderOwner + | AccountCategory::ProviderOperator + | AccountCategory::ProviderPlatform => "Provider", + AccountCategory::PlatformPayment => "Platform", + AccountCategory::Other(_) => "Other", + } + } + + /// Whether this account tab is visible in default (non-developer) mode. + pub fn is_visible_in_default_mode(&self) -> bool { + matches!( + self, + AccountCategory::Bip44 | AccountCategory::PlatformPayment + ) + } + /// Returns true if this account category is primarily used for key - /// derivation and proofs rather than holding funds. Used for account - /// dropdown label formatting. + /// derivation and proofs rather than holding funds. + #[allow(dead_code)] pub fn is_key_only(&self) -> bool { matches!( self, @@ -178,6 +208,7 @@ pub(crate) fn categorize_account_path( } #[derive(Clone, Debug)] +#[allow(dead_code)] pub struct AccountSummary { pub category: AccountCategory, pub label: String, @@ -273,7 +304,7 @@ mod tests { use dash_sdk::dpp::key_wallet::bip32::ChildNumber; #[test] - fn bip44_without_account_index_is_not_main_account() { + fn bip44_without_account_index_is_not_dash_core() { assert_eq!(AccountCategory::Bip44.label(None), "BIP44 Account"); } diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 8305f6e8c..70355e0dd 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -44,14 +44,26 @@ use dialogs::{ SendDialogState, }; -/// Tab selector for the wallet detail panel. -#[derive(Default, Clone, Copy, PartialEq)] -enum WalletViewTab { - #[default] - Balances, +/// Tab selector for the Accounts & Addresses section. +/// +/// Each tab corresponds to either an `AccountCategory` or the special Shielded +/// view. Visibility is controlled by developer mode: only DashCore, Platform, +/// and Shielded are shown by default; the rest appear in developer mode when +/// addresses of that type exist. +#[derive(Clone, PartialEq, Eq)] +enum AccountTab { + /// Regular account category (BIP44, PlatformPayment, CoinJoin, etc.) + Category(AccountCategory, Option), + /// Shielded wallet view (replaces the old top-level Shielded tab) Shielded, } +impl Default for AccountTab { + fn default() -> Self { + AccountTab::Category(AccountCategory::Bip44, Some(0)) + } +} + /// Refresh mode for dev mode dropdown - controls what gets refreshed #[derive(Clone, Copy, PartialEq, Eq, Default)] enum RefreshMode { @@ -118,10 +130,14 @@ pub struct WalletsBalancesScreen { utxo_page: usize, /// Selected refresh mode (only shown in dev mode) refresh_mode: RefreshMode, - /// Currently selected tab in the wallet detail panel - selected_tab: WalletViewTab, + /// Currently selected account tab in the Accounts & Addresses section + selected_account_tab: AccountTab, /// Shielded tab view component (lazily initialized per wallet) shielded_tab_view: Option, + /// Whether the balance breakdown section is expanded + balance_breakdown_expanded: bool, + /// Whether the Dev Tools popup is open + dev_tools_open: bool, /// Cached platform sync info: (last_sync_timestamp, last_sync_height) platform_sync_info: Option<(u64, u64)>, /// Core wallet selection dialog (shown when auto-detection fails) @@ -230,8 +246,10 @@ impl WalletsBalancesScreen { asset_lock_search_banner: None, utxo_page: 0, refresh_mode: RefreshMode::default(), - selected_tab: WalletViewTab::default(), + selected_account_tab: AccountTab::default(), shielded_tab_view: None, + balance_breakdown_expanded: app_context.is_developer_mode(), + dev_tools_open: false, platform_sync_info, core_wallet_dialog: None, pending_core_wallet_seed_hash: None, @@ -338,7 +356,7 @@ impl WalletsBalancesScreen { self.selected_wallet = wallet; self.selected_single_key_wallet = None; self.selected_account = None; - self.selected_tab = WalletViewTab::default(); + self.selected_account_tab = AccountTab::default(); self.shielded_tab_view = None; if let Some(hash) = seed_hash { @@ -588,31 +606,6 @@ impl WalletsBalancesScreen { DashColors::text_primary(ui.ctx().style().visuals.dark_mode), format!(" Balance: {}", Self::format_dash(current_balance)), ); - - ui.separator(); - - // Dev mode: Refresh mode selector - if self.app_context.is_developer_mode() { - ui.label( - egui::RichText::new("Refresh Mode:").color(DashColors::text_primary( - ui.ctx().style().visuals.dark_mode, - )), - ); - - ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { - ComboBox::from_id_salt("refresh_mode_selector") - .selected_text(self.refresh_mode.label()) - .show_ui(ui, |ui| { - for mode in RefreshMode::all_modes() { - ui.selectable_value( - &mut self.refresh_mode, - *mode, - mode.label(), - ); - } - }); - }); - } }); ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| { @@ -743,7 +736,7 @@ impl WalletsBalancesScreen { .as_ref() .is_some_and(|wallet_guard| wallet_guard.read().unwrap().is_open()); - // Only show "Add Receiving Address" button for Main Account (BIP44 account 0) + // Only show "Add Receiving Address" button for Dash Core account (BIP44 account 0) let is_main_account = self .selected_account .as_ref() @@ -1008,31 +1001,6 @@ impl WalletsBalancesScreen { .sum() } - fn render_wallet_overview(&self, ui: &mut Ui, wallet: &Wallet) { - let dark_mode = ui.ctx().style().visuals.dark_mode; - let total = wallet.total_balance_duffs(); - let platform = Self::platform_balance_duffs(wallet); - let combined = total + platform; - - ui.horizontal(|ui| { - ui.label(RichText::new(format!( - "Core balance: {}", - Self::format_dash(total) - ))); - }); - ui.label( - RichText::new(format!("Platform balance: {}", Self::format_dash(platform))) - .color(DashColors::text_primary(dark_mode)), - ); - if platform > 0 { - ui.label( - RichText::new(format!("Total: {}", Self::format_dash(combined))) - .color(DashColors::text_primary(dark_mode)) - .strong(), - ); - } - } - fn render_action_buttons(&mut self, ui: &mut Ui, ctx: &Context) -> AppAction { let mut action = AppAction::None; ui.add_space(10.0); @@ -1072,110 +1040,251 @@ impl WalletsBalancesScreen { action |= self.open_receive_dialog(ctx); } - if matches!( - self.app_context.network, - dash_sdk::dpp::dashcore::Network::Regtest - | dash_sdk::dpp::dashcore::Network::Devnet - ) && self.app_context.is_developer_mode() - && self.app_context.core_backend_mode() == CoreBackendMode::Rpc - && ui + // Dev Tools expandable button (developer mode only) + if self.app_context.is_developer_mode() { + let dev_tools_label = if self.dev_tools_open { + "Dev Tools \u{25BC}" + } else { + "Dev Tools \u{25B6}" + }; + if ui .button( - RichText::new("Mine") + RichText::new(dev_tools_label) .color(DashColors::text_primary(dark_mode)) .strong(), ) .clicked() - { - self.open_mine_dialog(); + { + self.dev_tools_open = !self.dev_tools_open; + } + } + + if self.refreshing { + ui.add(egui::Spinner::new().color(DashColors::DASH_BLUE)); } }); + + // Dev Tools expanded section + if self.app_context.is_developer_mode() && self.dev_tools_open { + ui.indent("dev_tools_indent", |ui| { + ui.horizontal(|ui| { + // Get Test Dash (opens browser to faucet) + if matches!( + self.app_context.network, + dash_sdk::dpp::dashcore::Network::Testnet + ) && ui.button("Get Test Dash").clicked() + { + ui.ctx().open_url(egui::OpenUrl::new_tab( + "https://faucet.testnet.networks.dash.org/", + )); + } + + // Mine button (Regtest/Devnet with RPC only) + if matches!( + self.app_context.network, + dash_sdk::dpp::dashcore::Network::Regtest + | dash_sdk::dpp::dashcore::Network::Devnet + ) && self.app_context.core_backend_mode() == CoreBackendMode::Rpc + && ui + .button( + RichText::new("Mine") + .color(DashColors::text_primary(dark_mode)) + .strong(), + ) + .clicked() + { + self.open_mine_dialog(); + } + + // Refresh Mode selector + ui.label( + RichText::new("Refresh Mode:").color(DashColors::text_primary(dark_mode)), + ); + ComboBox::from_id_salt("refresh_mode_selector_dev_tools") + .selected_text(self.refresh_mode.label()) + .show_ui(ui, |ui| { + for mode in RefreshMode::all_modes() { + ui.selectable_value(&mut self.refresh_mode, *mode, mode.label()); + } + }); + }); + }); + } + action } - fn render_accounts_section(&mut self, ui: &mut Ui, summaries: &[AccountSummary]) { + /// Build the list of visible account tabs based on current summaries and dev mode. + fn build_account_tabs(&self, summaries: &[AccountSummary]) -> Vec { + let developer_mode = self.app_context.is_developer_mode(); + let mut tabs: Vec = Vec::new(); + // Track which provider categories we have seen (they get grouped into one tab) + let mut has_provider = false; + + for summary in summaries { + let visible = if developer_mode { + true + } else { + summary.category.is_visible_in_default_mode() && summary.index == Some(0) + || summary.category == AccountCategory::PlatformPayment + }; + if !visible { + continue; + } + + // Group all Provider* categories into a single tab + let is_provider = matches!( + summary.category, + AccountCategory::ProviderVoting + | AccountCategory::ProviderOwner + | AccountCategory::ProviderOperator + | AccountCategory::ProviderPlatform + ); + if is_provider { + if has_provider { + continue; + } + has_provider = true; + } + + tabs.push(AccountTab::Category( + summary.category.clone(), + summary.index, + )); + } + + // Always add the Shielded tab (after Dash Core and Platform) + tabs.push(AccountTab::Shielded); + + tabs + } + + /// Render the Accounts & Addresses tab bar and content. + fn render_account_tabs(&mut self, ui: &mut Ui, summaries: &[AccountSummary]) -> AppAction { + let mut action = AppAction::None; + let dark_mode = ui.ctx().style().visuals.dark_mode; + ui.add_space(14.0); - ui.heading("Accounts"); + ui.heading( + RichText::new("Accounts & Addresses").color(DashColors::text_primary(dark_mode)), + ); ui.add_space(6.0); - if summaries.is_empty() { + if summaries.is_empty() && !matches!(self.selected_account_tab, AccountTab::Shielded) { ui.label("No account activity yet."); - return; + return action; } - let dark_mode = ui.ctx().style().visuals.dark_mode; + let tabs = self.build_account_tabs(summaries); - // Find the currently selected summary - let selected_summary = self.selected_account.as_ref().and_then(|(cat, idx)| { - summaries - .iter() - .find(|s| &s.category == cat && s.index == *idx) - }); + // Ensure the selected tab is still valid + if !tabs.contains(&self.selected_account_tab) + && let Some(first) = tabs.first() + { + self.selected_account_tab = first.clone(); + } - // Build the selected text for the dropdown - let selected_text = selected_summary - .map(|s| { - if s.category.is_key_only() { - s.label.clone() - } else if s.category == AccountCategory::PlatformPayment { - let credits_as_dash = s.platform_credits as f64 / CREDITS_PER_DUFF as f64 / 1e8; - format!("{} - {:.4} DASH", s.label, credits_as_dash) + // Tab bar + ui.horizontal_wrapped(|ui| { + for tab in &tabs { + let label = match tab { + AccountTab::Category(cat, idx) => cat.tab_label(*idx), + AccountTab::Shielded => "Shielded", + }; + let is_selected = &self.selected_account_tab == tab; + let text = if is_selected { + RichText::new(label).strong().color(DashColors::DASH_BLUE) } else { - format!("{} - {}", s.label, Self::format_dash(s.confirmed_balance)) + RichText::new(label).color(DashColors::text_secondary(dark_mode)) + }; + if ui.selectable_label(is_selected, text).clicked() { + self.selected_account_tab = tab.clone(); + // Sync the selected_account for address_table filtering + if let AccountTab::Category(cat, idx) = tab { + self.selected_account = Some((cat.clone(), *idx)); + } } - }) - .unwrap_or_else(|| "Select an account".to_string()); - - // Account dropdown selector - ComboBox::from_id_salt("account_selector") - .selected_text(&selected_text) - .width(ui.available_width() - 16.0) - .show_ui(ui, |ui| { - for summary in summaries { - let is_selected = self - .selected_account - .as_ref() - .map(|(cat, idx)| cat == &summary.category && *idx == summary.index) - .unwrap_or(false); - - let label = if summary.category.is_key_only() { - summary.label.clone() - } else if summary.category == AccountCategory::PlatformPayment { - let credits_as_dash = - summary.platform_credits as f64 / CREDITS_PER_DUFF as f64 / 1e8; - format!("{} - {:.4} DASH", summary.label, credits_as_dash) - } else { - format!( - "{} - {}", - summary.label, - Self::format_dash(summary.confirmed_balance) - ) - }; + } + }); + ui.separator(); + ui.add_space(4.0); - if ui.selectable_label(is_selected, &label).clicked() { - self.selected_account = Some((summary.category.clone(), summary.index)); - } + // Tab content + match &self.selected_account_tab.clone() { + AccountTab::Shielded => { + let seed_hash = self + .selected_wallet + .as_ref() + .and_then(|w| w.read().ok().map(|g| g.seed_hash())); + if let Some(seed_hash) = seed_hash { + let shielded_view = self + .shielded_tab_view + .get_or_insert_with(|| ShieldedTabView::new(&self.app_context, seed_hash)); + shielded_view.update_seed_hash(seed_hash); + shielded_view.update_app_context(&self.app_context); + action |= shielded_view.ui(ui); + } + } + AccountTab::Category(cat, idx) => { + // Show description for the selected account category + if let Some(description) = cat.description() { + ui.label( + RichText::new(description) + .color(DashColors::text_secondary(dark_mode)) + .italics() + .size(12.0), + ); + ui.add_space(4.0); } - }); - // Show description of the selected account below the dropdown - if let Some(summary) = selected_summary - && let Some(description) = summary.category.description() - { - ui.add_space(4.0); - ui.label( - RichText::new(description) - .color(DashColors::text_secondary(dark_mode)) - .italics() - .size(12.0), - ); + // When in dev mode for provider tabs, filter to show all + // provider-type addresses + let is_provider_group = matches!( + cat, + AccountCategory::ProviderVoting + | AccountCategory::ProviderOwner + | AccountCategory::ProviderOperator + | AccountCategory::ProviderPlatform + ); + + if is_provider_group { + // Show all provider addresses, not just one sub-type + self.selected_account = Some((cat.clone(), *idx)); + } else { + self.selected_account = Some((cat.clone(), *idx)); + } + + // Addresses heading with zero-balance filter + ui.horizontal(|ui| { + let addresses_heading = format!("Addresses ({})", cat.label(*idx)); + ui.heading( + RichText::new(addresses_heading).color(DashColors::text_primary(dark_mode)), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.checkbox( + &mut self.show_zero_balance_addresses, + "Show zero-balance addresses", + ); + }); + }); + ui.add_space(8.0); + action |= self.render_address_table(ui); + + // Bottom options (Add Receiving Address for Dash Core tab) + self.render_bottom_options(ui); + + // Asset Locks section — only on the Dash Core tab + if *cat == AccountCategory::Bip44 && *idx == Some(0) { + ui.add_space(16.0); + action |= self.render_wallet_asset_locks(ui); + } + } } + + action } fn render_transactions_section(&self, ui: &mut Ui) { - ui.add_space(10.0); - // TODO: Synchronize transactions display with selected account type - // (main account -> Core transactions, platform account -> platform state transitions, etc.) - ui.heading("Dash Core Transactions"); let Some(wallet_arc) = self.selected_wallet.as_ref() else { ui.label("Select a wallet to view its transaction history."); return; @@ -1190,6 +1299,7 @@ impl WalletsBalancesScreen { } let dark_mode = ui.ctx().style().visuals.dark_mode; + let show_fee = self.app_context.is_developer_mode(); let mut order: Vec = (0..wallet_guard.transactions.len()).collect(); order.sort_by(|&a, &b| { wallet_guard.transactions[b] @@ -1203,12 +1313,18 @@ impl WalletsBalancesScreen { }); let row_height = 26.0; - TableBuilder::new(ui) + let mut builder = TableBuilder::new(ui) .id_salt("transactions_table") .striped(true) .column(Column::initial(150.0)) // Date .column(Column::initial(80.0)) // Type - .column(Column::initial(120.0)) // Amount + .column(Column::initial(120.0)); // Amount + + if show_fee { + builder = builder.column(Column::initial(100.0)); // Fee + } + + builder .column(Column::initial(150.0)) // Status .column(Column::remainder()) // TxID .header(row_height, |mut header| { @@ -1233,6 +1349,15 @@ impl WalletsBalancesScreen { .color(DashColors::text_primary(dark_mode)), ); }); + if show_fee { + header.col(|ui| { + ui.label( + RichText::new("Fee") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + }); + } header.col(|ui| { ui.label( RichText::new("Status") @@ -1263,6 +1388,15 @@ impl WalletsBalancesScreen { Self::transaction_amount_display(tx, dark_mode); ui.label(RichText::new(amount_text).color(amount_color).strong()); }); + if show_fee { + row.col(|ui| { + let fee_text = tx + .fee + .map(Self::format_dash) + .unwrap_or_else(|| "-".to_string()); + ui.label(fee_text); + }); + } row.col(|ui| { ui.label(Self::format_transaction_status(tx)); }); @@ -1543,6 +1677,39 @@ impl WalletsBalancesScreen { ); } + /// Render the collapsible balance breakdown section. + fn render_balance_breakdown(&mut self, ui: &mut Ui, wallet: &Wallet) { + let dark_mode = ui.ctx().style().visuals.dark_mode; + let core_balance = wallet.total_balance_duffs(); + let platform_balance = Self::platform_balance_duffs(wallet); + let total = core_balance + platform_balance; + + // Total balance (always visible) + ui.label( + RichText::new(format!("Balance: {}", Self::format_dash(total))) + .color(DashColors::text_primary(dark_mode)) + .size(20.0) + .strong(), + ); + + // Collapsible breakdown + let header = egui::CollapsingHeader::new( + RichText::new("Balance breakdown") + .size(13.0) + .color(DashColors::text_secondary(dark_mode)), + ) + .id_salt("balance_breakdown") + .default_open(self.balance_breakdown_expanded); + + header.show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(format!("Core: {}", Self::format_dash(core_balance))); + ui.label(" | "); + ui.label(format!("Platform: {}", Self::format_dash(platform_balance))); + }); + }); + } + fn render_wallet_detail_panel(&mut self, ui: &mut Ui, ctx: &Context) -> AppAction { let Some(wallet_arc) = self.selected_wallet.clone() else { self.render_no_wallets_view(ui); @@ -1571,6 +1738,7 @@ impl WalletsBalancesScreen { .fill(DashColors::surface(dark_mode)) .inner_margin(Margin::symmetric(18, 16)) .show(col, |ui| { + // --- 1. Wallet Header --- ui.horizontal(|ui| { ui.heading( RichText::new(alias.clone()) @@ -1578,125 +1746,48 @@ impl WalletsBalancesScreen { .size(25.0), ); - ui.with_layout( - egui::Layout::right_to_left(egui::Align::Center), - |ui| { - if self.refreshing { - ui.add(egui::Spinner::new().color(DashColors::DASH_BLUE)) - } else { - ui.add(egui::Label::new("")) - } - }, - ); + if self.app_context.is_developer_mode() { + ui.label( + RichText::new("[DEV]") + .color(DashColors::text_secondary(dark_mode)) + .size(12.0), + ); + } }); - // Tab bar: Balances | Shielded - ui.add_space(6.0); - ui.horizontal(|ui| { - let balances_text = if self.selected_tab == WalletViewTab::Balances { - RichText::new("Balances") - .strong() - .color(DashColors::DASH_BLUE) - } else { - RichText::new("Balances") - .color(DashColors::text_secondary(dark_mode)) - }; - if ui - .selectable_label( - self.selected_tab == WalletViewTab::Balances, - balances_text, - ) - .clicked() - { - self.selected_tab = WalletViewTab::Balances; - } + // --- 2. Balance with collapsible breakdown --- + { + let wallet = wallet_arc.read().unwrap(); + self.render_balance_breakdown(ui, &wallet); + } - let shielded_text = if self.selected_tab == WalletViewTab::Shielded { - RichText::new("Shielded") - .strong() - .color(DashColors::DASH_BLUE) - } else { - RichText::new("Shielded") - .color(DashColors::text_secondary(dark_mode)) - }; - if ui - .selectable_label( - self.selected_tab == WalletViewTab::Shielded, - shielded_text, - ) - .clicked() - { - self.selected_tab = WalletViewTab::Shielded; - } - }); - ui.separator(); - ui.add_space(4.0); - - match self.selected_tab { - WalletViewTab::Balances => { - let summaries = { - let wallet = wallet_arc.read().unwrap(); - self.render_wallet_overview(ui, &wallet); - collect_account_summaries(&wallet, self.app_context.network) - }; + // --- 3. Action Buttons (Send, Receive, Dev Tools) --- + action |= self.render_action_buttons(ui, ctx); - self.ensure_account_selection(&summaries); - action |= self.render_action_buttons(ui, ctx); - ui.add_space(10.0); - ui.separator(); - self.render_accounts_section(ui, &summaries); - ui.add_space(10.0); - ui.separator(); - ui.add_space(10.0); - let addresses_heading = self - .selected_account - .as_ref() - .map(|(category, index)| { - format!("Addresses ({})", category.label(*index)) - }) - .unwrap_or_else(|| "Addresses".to_string()); - ui.horizontal(|ui| { - ui.heading( - RichText::new(addresses_heading) - .color(DashColors::text_primary(dark_mode)), - ); - ui.with_layout( - egui::Layout::right_to_left(egui::Align::Center), - |ui| { - ui.checkbox( - &mut self.show_zero_balance_addresses, - "Show zero-balance addresses", - ); - }, - ); - }); - ui.add_space(8.0); - action |= self.render_address_table(ui); - - // Transactions section - requires SPV which is dev mode only - if self.app_context.is_developer_mode() { - ui.add_space(10.0); - ui.separator(); - self.render_transactions_section(ui); - } + // --- 4. Transaction History (collapsible) --- + ui.add_space(10.0); + ui.separator(); + let tx_header = egui::CollapsingHeader::new( + RichText::new("Transaction History") + .size(16.0) + .color(DashColors::text_primary(dark_mode)), + ) + .id_salt("transaction_history") + .default_open(false); + tx_header.show(ui, |ui| { + self.render_transactions_section(ui); + }); - ui.add_space(14.0); - self.render_bottom_options(ui); + // --- 5. Accounts & Addresses (tabs) --- + ui.add_space(10.0); + ui.separator(); - ui.add_space(16.0); - action |= self.render_wallet_asset_locks(ui); - } - WalletViewTab::Shielded => { - let seed_hash = wallet_arc.read().unwrap().seed_hash(); - let shielded_view = - self.shielded_tab_view.get_or_insert_with(|| { - ShieldedTabView::new(&self.app_context, seed_hash) - }); - shielded_view.update_seed_hash(seed_hash); - shielded_view.update_app_context(&self.app_context); - action |= shielded_view.ui(ui); - } - } + let summaries = { + let wallet = wallet_arc.read().unwrap(); + collect_account_summaries(&wallet, self.app_context.network) + }; + self.ensure_account_selection(&summaries); + action |= self.render_account_tabs(ui, &summaries); }); }); }); From 51b16b2044e85db7535a89ed056dd585afa3cb70 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:09:05 +0100 Subject: [PATCH 055/147] fix(ui): use AddressInput component in Mine dialog for core address selection Replace the manual ComboBox address selector in the Mine Blocks dialog with the unified AddressInput component, configured for Core-only addresses with selection-only mode from the current wallet. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/wallets_screen/dialogs.rs | 113 +++++------------------ 1 file changed, 24 insertions(+), 89 deletions(-) diff --git a/src/ui/wallets/wallets_screen/dialogs.rs b/src/ui/wallets/wallets_screen/dialogs.rs index d2c81f583..6e10fe72e 100644 --- a/src/ui/wallets/wallets_screen/dialogs.rs +++ b/src/ui/wallets/wallets_screen/dialogs.rs @@ -2,11 +2,13 @@ use crate::app::AppAction; use crate::backend_task::BackendTask; use crate::backend_task::core::{CoreTask, PaymentRecipient, WalletPaymentRequest}; use crate::backend_task::wallet::WalletTask; +use crate::model::address::{AddressKind, ValidatedAddress}; use crate::model::amount::Amount; use crate::model::secret::Secret; use crate::model::wallet::{DerivationPathHelpers, Wallet}; use crate::ui::MessageType; use crate::ui::components::MessageBanner; +use crate::ui::components::address_input::AddressInput; use crate::ui::components::amount_input::AmountInput; use crate::ui::components::component_trait::{Component, ComponentResponse}; use crate::ui::helpers::clicked_outside_window; @@ -88,8 +90,8 @@ pub(super) struct FundPlatformAddressDialogState { #[derive(Default)] pub(super) struct MineDialogState { pub is_open: bool, - pub core_addresses: Vec<(String, u64)>, - pub selected_address_index: usize, + pub address_input: Option, + pub validated_address: Option, pub block_count_str: String, pub error: Option, } @@ -1283,38 +1285,20 @@ impl WalletsBalancesScreen { return; }; + let address_input = AddressInput::new(self.app_context.network) + .with_label("Mine to address:") + .with_address_kinds(&[AddressKind::Core]) + .with_wallets(&[wallet]) + .with_selection_only(true) + .with_full_addresses(true); + self.mine_dialog = MineDialogState { is_open: true, + address_input: Some(address_input), + validated_address: None, block_count_str: "1".to_string(), - ..Default::default() + error: None, }; - - // Reuse the same address loading pattern as receive dialog - self.load_core_addresses_for_mine(&wallet); - } - - fn load_core_addresses_for_mine(&mut self, wallet: &Arc>) { - match self.load_bip44_external_addresses(wallet) { - Ok(addresses) if addresses.is_empty() => { - match self.generate_new_core_receive_address(wallet) { - Ok((address, balance)) => { - self.mine_dialog.core_addresses = vec![(address, balance)]; - self.mine_dialog.selected_address_index = 0; - } - Err(err) => { - self.mine_dialog.error = Some(err); - self.mine_dialog.core_addresses.clear(); - } - } - } - Ok(addresses) => { - self.mine_dialog.core_addresses = addresses; - self.mine_dialog.selected_address_index = 0; - } - Err(err) => { - self.mine_dialog.error = Some(err); - } - } } pub(super) fn render_mine_dialog(&mut self, ctx: &Context) -> AppAction { @@ -1343,46 +1327,10 @@ impl WalletsBalancesScreen { ); ui.add_space(10.0); - // Address selector - if !self.mine_dialog.core_addresses.is_empty() { - ui.label("Address:"); - ComboBox::from_id_salt("mine_addr_selector") - .selected_text( - self.mine_dialog - .core_addresses - .get(self.mine_dialog.selected_address_index) - .map(|(addr, balance)| { - let balance_dash = *balance as f64 / 1e8; - format!( - "{}... ({:.4} DASH)", - &addr[..12.min(addr.len())], - balance_dash - ) - }) - .unwrap_or_default(), - ) - .width(ui.available_width() - 16.0) - .show_ui(ui, |ui| { - for (idx, (addr, balance)) in - self.mine_dialog.core_addresses.iter().enumerate() - { - let balance_dash = *balance as f64 / 1e8; - let label = format!( - "{}... ({:.4} DASH)", - &addr[..12.min(addr.len())], - balance_dash - ); - if ui - .selectable_label( - idx == self.mine_dialog.selected_address_index, - label, - ) - .clicked() - { - self.mine_dialog.selected_address_index = idx; - } - } - }); + // Address selector using AddressInput component + if let Some(address_input) = self.mine_dialog.address_input.as_mut() { + let resp = address_input.show(ui); + resp.inner.update(&mut self.mine_dialog.validated_address); } ui.add_space(10.0); @@ -1433,7 +1381,6 @@ impl WalletsBalancesScreen { ui.add_space(8.0); if ComponentStyles::add_primary_button(ui, "Mine").clicked() { - // Validate and dispatch const MAX_MINE_BLOCKS: u64 = 1_000; let block_count: u64 = match self.mine_dialog.block_count_str.trim().parse() { @@ -1453,25 +1400,15 @@ impl WalletsBalancesScreen { } }; - let Some((addr_str, _)) = self - .mine_dialog - .core_addresses - .get(self.mine_dialog.selected_address_index) - else { - self.mine_dialog.error = Some("No address selected".to_string()); + let Some(validated) = &self.mine_dialog.validated_address else { + self.mine_dialog.error = + Some("Select an address first".to_string()); return; }; - let address = match addr_str - .parse::>() - .and_then(|a| a.require_network(self.app_context.network)) - { - Ok(addr) => addr, - Err(e) => { - self.mine_dialog.error = - Some(format!("Invalid address: {}", e)); - return; - } + let Some(address) = validated.as_core().cloned() else { + self.mine_dialog.error = Some("Select a Core address".to_string()); + return; }; let Some(wallet) = self.selected_wallet.clone() else { @@ -1498,8 +1435,6 @@ impl WalletsBalancesScreen { open = false; } - // X button sets `open` to false; Cancel/Mine reset dialog state - // (which sets is_open to false) inside the closure. if !open || !self.mine_dialog.is_open { self.mine_dialog = MineDialogState::default(); } From 9bae9f809c03bdcb9c56807c0027904c0395aded Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:10:10 +0100 Subject: [PATCH 056/147] fix(ui): trigger shielded balance refresh after all shielding operations After Shield, Shield from Core, Send Private, Unshield, and Send Dash (with shielded source) complete successfully, automatically dispatch a SyncNotes task so the shielded tab shows updated balances without manual refresh. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/send_screen.rs | 22 ++++++++++++++++--- src/ui/wallets/shield_credits_screen.rs | 18 +++++++++++++++ .../wallets/shield_from_asset_lock_screen.rs | 15 ++++++++++++- src/ui/wallets/shielded_send_screen.rs | 15 ++++++++++++- src/ui/wallets/unshield_credits_screen.rs | 19 +++++++++++++++- 5 files changed, 83 insertions(+), 6 deletions(-) diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index 102f2fad2..534747d0d 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -391,6 +391,9 @@ pub struct WalletSendScreen { // Wallet unlock wallet_unlock_popup: WalletUnlockPopup, wallet_open_attempted: bool, + + /// Queued task to dispatch on next frame (e.g., sync shielded notes after send). + pending_refresh_task: Option, } impl WalletSendScreen { @@ -419,6 +422,7 @@ impl WalletSendScreen { send_banner: None, wallet_unlock_popup: WalletUnlockPopup::new(), wallet_open_attempted: false, + pending_refresh_task: None, } } @@ -2720,7 +2724,13 @@ impl WalletSendScreen { impl ScreenLike for WalletSendScreen { fn ui(&mut self, ctx: &Context) -> AppAction { - let mut action = add_top_panel( + let mut action = self + .pending_refresh_task + .take() + .map(AppAction::BackendTask) + .unwrap_or(AppAction::None); + + action |= add_top_panel( ctx, &self.app_context, vec![("Wallets", AppAction::PopScreen), ("Send", AppAction::None)], @@ -2847,22 +2857,28 @@ impl ScreenLike for WalletSendScreen { SendStatus::Complete("Platform credits transferred successfully!".to_string()); } crate::backend_task::BackendTaskSuccessResult::ShieldedTransferComplete { + seed_hash, amount, - .. } => { self.send_status = SendStatus::Complete(format!( "Shielded transfer of {} complete!", format_credits_as_dash(amount) )); + self.pending_refresh_task = Some(crate::backend_task::BackendTask::ShieldedTask( + crate::backend_task::shielded::ShieldedTask::SyncNotes { seed_hash }, + )); } crate::backend_task::BackendTaskSuccessResult::ShieldedCreditsUnshielded { + seed_hash, amount, - .. } => { self.send_status = SendStatus::Complete(format!( "Unshielded {} to platform address!", format_credits_as_dash(amount) )); + self.pending_refresh_task = Some(crate::backend_task::BackendTask::ShieldedTask( + crate::backend_task::shielded::ShieldedTask::SyncNotes { seed_hash }, + )); } _ => { // Ignore other results diff --git a/src/ui/wallets/shield_credits_screen.rs b/src/ui/wallets/shield_credits_screen.rs index 135474d0a..51449121e 100644 --- a/src/ui/wallets/shield_credits_screen.rs +++ b/src/ui/wallets/shield_credits_screen.rs @@ -47,6 +47,8 @@ pub struct ShieldCreditsScreen { batch_remaining: u32, /// Queued task to dispatch on next frame (for sequential batch mode). pending_next_task: Option, + /// Queued sync task to dispatch on next frame after successful operation. + pending_refresh_task: Option, /// Per-operation progress for parallel batch mode. batch_stages: Option>>>, /// JSON of a failed state transition to show in the popup. @@ -84,6 +86,7 @@ impl ShieldCreditsScreen { batch_failed: 0, batch_remaining: 0, pending_next_task: None, + pending_refresh_task: None, batch_stages: None, json_preview: None, } @@ -354,6 +357,11 @@ impl ScreenLike for ShieldCreditsScreen { action = AppAction::BackendTask(task); } + // Dispatch pending refresh task (sync notes after successful shield) + if let Some(task) = self.pending_refresh_task.take() { + action |= AppAction::BackendTask(task); + } + island_central_panel(ctx, |ui| { ui.heading("Shield Credits"); ui.add_space(10.0); @@ -700,11 +708,21 @@ impl ScreenLike for ShieldCreditsScreen { self.check_batch_complete(); if self.status == Status::BatchInProgress { self.queue_next_sequential(); + } else { + // Batch complete — sync shielded notes + self.pending_refresh_task = + Some(BackendTask::ShieldedTask(ShieldedTask::SyncNotes { + seed_hash: self.seed_hash, + })); } } else { self.status = Status::Complete; let dash = amount as f64 / CREDITS_PER_DUFF as f64 / 1e8; self.success_message = Some(format!("Successfully shielded {:.8} DASH", dash)); + self.pending_refresh_task = + Some(BackendTask::ShieldedTask(ShieldedTask::SyncNotes { + seed_hash: self.seed_hash, + })); } } _ => {} diff --git a/src/ui/wallets/shield_from_asset_lock_screen.rs b/src/ui/wallets/shield_from_asset_lock_screen.rs index 6ff9931f1..ee0ba00a1 100644 --- a/src/ui/wallets/shield_from_asset_lock_screen.rs +++ b/src/ui/wallets/shield_from_asset_lock_screen.rs @@ -32,6 +32,8 @@ pub struct ShieldFromAssetLockScreen { status: Status, error_message: Option, success_message: Option, + /// Queued task to dispatch on next frame (e.g., sync notes after successful shield). + pending_refresh_task: Option, } impl ShieldFromAssetLockScreen { @@ -56,13 +58,20 @@ impl ShieldFromAssetLockScreen { status: Status::NotStarted, error_message: None, success_message: None, + pending_refresh_task: None, } } } impl ScreenLike for ShieldFromAssetLockScreen { fn ui(&mut self, ctx: &Context) -> AppAction { - let mut action = add_top_panel( + let mut action = self + .pending_refresh_task + .take() + .map(AppAction::BackendTask) + .unwrap_or(AppAction::None); + + action |= add_top_panel( ctx, &self.app_context, vec![ @@ -176,6 +185,10 @@ impl ScreenLike for ShieldFromAssetLockScreen { "Successfully shielded {:.8} DASH from core wallet", dash )); + self.pending_refresh_task = + Some(BackendTask::ShieldedTask(ShieldedTask::SyncNotes { + seed_hash: self.seed_hash, + })); } _ => {} } diff --git a/src/ui/wallets/shielded_send_screen.rs b/src/ui/wallets/shielded_send_screen.rs index a4583251c..b6ac92a59 100644 --- a/src/ui/wallets/shielded_send_screen.rs +++ b/src/ui/wallets/shielded_send_screen.rs @@ -33,6 +33,8 @@ pub struct ShieldedSendScreen { status: Status, error_message: Option, success_message: Option, + /// Queued task to dispatch on next frame (e.g., sync notes after successful send). + pending_refresh_task: Option, } impl ShieldedSendScreen { @@ -55,6 +57,7 @@ impl ShieldedSendScreen { status: Status::NotStarted, error_message: None, success_message: None, + pending_refresh_task: None, } } @@ -80,7 +83,13 @@ impl ShieldedSendScreen { impl ScreenLike for ShieldedSendScreen { fn ui(&mut self, ctx: &Context) -> AppAction { - let mut action = add_top_panel( + let mut action = self + .pending_refresh_task + .take() + .map(AppAction::BackendTask) + .unwrap_or(AppAction::None); + + action |= add_top_panel( ctx, &self.app_context, vec![ @@ -204,6 +213,10 @@ impl ScreenLike for ShieldedSendScreen { let dash = amount as f64 / CREDITS_PER_DUFF as f64 / 1e8; self.success_message = Some(format!("Successfully sent {:.8} DASH privately", dash)); + self.pending_refresh_task = + Some(BackendTask::ShieldedTask(ShieldedTask::SyncNotes { + seed_hash: self.seed_hash, + })); } _ => {} } diff --git a/src/ui/wallets/unshield_credits_screen.rs b/src/ui/wallets/unshield_credits_screen.rs index 29fb4f463..90f8a6a16 100644 --- a/src/ui/wallets/unshield_credits_screen.rs +++ b/src/ui/wallets/unshield_credits_screen.rs @@ -36,6 +36,8 @@ pub struct UnshieldCreditsScreen { status: Status, error_message: Option, success_message: Option, + /// Queued task to dispatch on next frame (e.g., sync notes after successful unshield). + pending_refresh_task: Option, } impl UnshieldCreditsScreen { @@ -59,13 +61,20 @@ impl UnshieldCreditsScreen { status: Status::NotStarted, error_message: None, success_message: None, + pending_refresh_task: None, } } } impl ScreenLike for UnshieldCreditsScreen { fn ui(&mut self, ctx: &Context) -> AppAction { - let mut action = add_top_panel( + let mut action = self + .pending_refresh_task + .take() + .map(AppAction::BackendTask) + .unwrap_or(AppAction::None); + + action |= add_top_panel( ctx, &self.app_context, vec![ @@ -236,6 +245,10 @@ impl ScreenLike for UnshieldCreditsScreen { "Successfully unshielded {:.8} DASH to platform address", dash )); + self.pending_refresh_task = + Some(BackendTask::ShieldedTask(ShieldedTask::SyncNotes { + seed_hash: self.seed_hash, + })); } BackendTaskSuccessResult::ShieldedWithdrawalComplete { seed_hash, amount } if seed_hash == self.seed_hash => @@ -246,6 +259,10 @@ impl ScreenLike for UnshieldCreditsScreen { "Successfully withdrew {:.8} DASH to core address", dash )); + self.pending_refresh_task = + Some(BackendTask::ShieldedTask(ShieldedTask::SyncNotes { + seed_hash: self.seed_hash, + })); } _ => {} } From ae6365246043d2223e49b7d94f7a9e28091221f6 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:12:41 +0100 Subject: [PATCH 057/147] chore: demote cookie auth fallback log to trace level Co-Authored-By: Claude Opus 4.6 --- src/backend_task/core/mod.rs | 2 +- src/context/mod.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/backend_task/core/mod.rs b/src/backend_task/core/mod.rs index afcb3b786..867723d5e 100644 --- a/src/backend_task/core/mod.rs +++ b/src/backend_task/core/mod.rs @@ -452,7 +452,7 @@ impl AppContext { let client = match Client::new(&addr, Auth::CookieFile(cookie_path.clone())) { Ok(client) => client, Err(_) => { - tracing::info!( + tracing::trace!( "Failed to authenticate using .cookie file at {:?}, falling back to user/pass", cookie_path ); diff --git a/src/context/mod.rs b/src/context/mod.rs index 3c0841a88..2763ace82 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -570,7 +570,7 @@ impl AppContext { if let Ok(client) = Client::new(url, Auth::CookieFile(cookie_path.clone())) { return Ok(client); } - tracing::debug!( + tracing::trace!( "Failed to authenticate using .cookie file at {:?}, falling back to user/pass", cookie_path, ); From 8dae2955363db19629f0f7612edb4a68dc169211 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:13:55 +0100 Subject: [PATCH 058/147] fix(ui): prevent transaction list showing wrong wallet data Add defensive checks in render_transactions_section to prevent cross-wallet transaction leakage: 1. Verify the selected wallet Arc matches the canonical one in app_context.wallets (guards against stale references). 2. Filter displayed transactions to only those with at least one output matching the wallet's known addresses (prevents showing transactions from other wallets that leaked in via a non-wallet-scoped RPC endpoint). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/wallets_screen/mod.rs | 44 +++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 8305f6e8c..b7b5f57f7 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -1181,7 +1181,26 @@ impl WalletsBalancesScreen { return; }; + // Defensive check: verify the selected wallet Arc matches the one in + // app_context.wallets. If they diverge (stale reference), skip rendering + // to avoid showing another wallet's data. let wallet_guard = wallet_arc.read().unwrap(); + let selected_seed_hash = wallet_guard.seed_hash(); + let arc_matches = self + .app_context + .wallets + .read() + .ok() + .and_then(|wallets| wallets.get(&selected_seed_hash).cloned()) + .is_some_and(|canonical| Arc::ptr_eq(wallet_arc, &canonical)); + if !arc_matches { + tracing::warn!( + "selected_wallet Arc does not match app_context.wallets — skipping transaction render" + ); + ui.label("Wallet data is being updated. Please re-select the wallet."); + return; + } + if wallet_guard.transactions.is_empty() { ui.label( "No transactions found. Try refreshing your wallet to load transaction history.", @@ -1189,8 +1208,31 @@ impl WalletsBalancesScreen { return; } + // Filter transactions to only those involving this wallet's addresses. + // This prevents showing transactions from other wallets that may have + // leaked in via a non-wallet-scoped RPC endpoint. + let wallet_addresses: std::collections::HashSet<&Address> = + wallet_guard.known_addresses.keys().collect(); + let relevant_indices: Vec = (0..wallet_guard.transactions.len()) + .filter(|&i| { + let tx = &wallet_guard.transactions[i]; + tx.transaction.output.iter().any(|output| { + Address::from_script(&output.script_pubkey, self.app_context.network) + .ok() + .is_some_and(|addr| wallet_addresses.contains(&addr)) + }) + }) + .collect(); + + if relevant_indices.is_empty() { + ui.label( + "No transactions found. Try refreshing your wallet to load transaction history.", + ); + return; + } + let dark_mode = ui.ctx().style().visuals.dark_mode; - let mut order: Vec = (0..wallet_guard.transactions.len()).collect(); + let mut order = relevant_indices; order.sort_by(|&a, &b| { wallet_guard.transactions[b] .timestamp From f1f8ba9afc0e9e58cc3dd3e5edd76eeea9c98e99 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:14:00 +0100 Subject: [PATCH 059/147] fix(ui): auto-show zero-balance addresses when wallet is empty When all addresses have zero balance and there are fewer than 5 addresses total, bypass the zero-balance filter so new/empty wallets show their addresses instead of a blank list. The checkbox remains functional for wallets with balances. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/wallets_screen/address_table.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/ui/wallets/wallets_screen/address_table.rs b/src/ui/wallets/wallets_screen/address_table.rs index 198a8947d..ddcada425 100644 --- a/src/ui/wallets/wallets_screen/address_table.rs +++ b/src/ui/wallets/wallets_screen/address_table.rs @@ -204,10 +204,22 @@ impl WalletsBalancesScreen { let account_address_count = address_data.len(); + // Auto-show zero-balance addresses when the wallet is nearly empty: + // fewer than 5 addresses total and none have a balance. This prevents + // new/empty wallets from showing a blank address list. + let all_zero_balance = !address_data.iter().any(|d| { + if d.account_category == AccountCategory::PlatformPayment { + d.platform_credits > 0 + } else { + d.balance > 0 + } + }); + let auto_show = account_address_count < 5 && all_zero_balance; + // INTENTIONAL(CMT-002): Zero-balance filter treats key-only addresses the same as all // others. The old exception (always showing key-only addresses) was removed intentionally // to reduce UI clutter — key-only accounts with no balance carry no actionable information. - if !self.show_zero_balance_addresses { + if !self.show_zero_balance_addresses && !auto_show { address_data.retain(|data| { if data.account_category == AccountCategory::PlatformPayment { data.platform_credits > 0 From 565cbd231bb9857fbb61d4d8894fb946a9bd4fd2 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:28:46 +0100 Subject: [PATCH 060/147] =?UTF-8?q?fix(ui):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20shielded=20balance,=20dev=20tools=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Include shielded balance in the collapsible balance breakdown 2. Add shielded balance to the total wallet balance everywhere 3. Right-align the Dev Tools button in the action row 4. Always show downward arrow on Dev Tools button 5. Dev Tools opens as a vertical dropdown popup instead of a horizontal inline row Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/wallets_screen/mod.rs | 149 +++++++++++++++------------ 1 file changed, 82 insertions(+), 67 deletions(-) diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 70355e0dd..a8db22c94 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -136,8 +136,6 @@ pub struct WalletsBalancesScreen { shielded_tab_view: Option, /// Whether the balance breakdown section is expanded balance_breakdown_expanded: bool, - /// Whether the Dev Tools popup is open - dev_tools_open: bool, /// Cached platform sync info: (last_sync_timestamp, last_sync_height) platform_sync_info: Option<(u64, u64)>, /// Core wallet selection dialog (shown when auto-detection fails) @@ -249,7 +247,6 @@ impl WalletsBalancesScreen { selected_account_tab: AccountTab::default(), shielded_tab_view: None, balance_breakdown_expanded: app_context.is_developer_mode(), - dev_tools_open: false, platform_sync_info, core_wallet_dialog: None, pending_core_wallet_seed_hash: None, @@ -494,7 +491,9 @@ impl WalletsBalancesScreen { let guard = wallet.read().unwrap(); let core_balance = guard.total_balance_duffs(); let platform_balance = Self::platform_balance_duffs(&guard); - let balance_dash = (core_balance + platform_balance) as f64 * 1e-8; + let shielded_balance = self.shielded_balance_duffs(&guard.seed_hash()); + let balance_dash = + (core_balance + platform_balance + shielded_balance) as f64 * 1e-8; let label = format!( "HD: {} ({:.4} DASH)", guard.alias.clone().unwrap_or_else(|| "Unnamed".to_string()), @@ -558,7 +557,8 @@ impl WalletsBalancesScreen { .map(|g| { let core = g.total_balance_duffs(); let platform = Self::platform_balance_duffs(&g); - core + platform + let shielded = self.shielded_balance_duffs(&g.seed_hash()); + core + platform + shielded }) .unwrap_or(0) } else if let Some(wallet) = &self.selected_single_key_wallet { @@ -1001,6 +1001,15 @@ impl WalletsBalancesScreen { .sum() } + fn shielded_balance_duffs(&self, seed_hash: &WalletSeedHash) -> u64 { + self.app_context + .shielded_states + .lock() + .ok() + .and_then(|states| states.get(seed_hash).map(|s| s.shielded_balance)) + .unwrap_or(0) + } + fn render_action_buttons(&mut self, ui: &mut Ui, ctx: &Context) -> AppAction { let mut action = AppAction::None; ui.add_space(10.0); @@ -1040,76 +1049,79 @@ impl WalletsBalancesScreen { action |= self.open_receive_dialog(ctx); } - // Dev Tools expandable button (developer mode only) - if self.app_context.is_developer_mode() { - let dev_tools_label = if self.dev_tools_open { - "Dev Tools \u{25BC}" - } else { - "Dev Tools \u{25B6}" - }; - if ui - .button( - RichText::new(dev_tools_label) - .color(DashColors::text_primary(dark_mode)) - .strong(), - ) - .clicked() - { - self.dev_tools_open = !self.dev_tools_open; - } - } - if self.refreshing { ui.add(egui::Spinner::new().color(DashColors::DASH_BLUE)); } - }); - // Dev Tools expanded section - if self.app_context.is_developer_mode() && self.dev_tools_open { - ui.indent("dev_tools_indent", |ui| { - ui.horizontal(|ui| { - // Get Test Dash (opens browser to faucet) - if matches!( - self.app_context.network, - dash_sdk::dpp::dashcore::Network::Testnet - ) && ui.button("Get Test Dash").clicked() - { - ui.ctx().open_url(egui::OpenUrl::new_tab( - "https://faucet.testnet.networks.dash.org/", - )); - } + // Dev Tools dropdown button (developer mode only), right-aligned + if self.app_context.is_developer_mode() { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let dev_tools_response = ui.button( + RichText::new("Dev Tools \u{25BC}") + .color(DashColors::text_primary(dark_mode)) + .strong(), + ); - // Mine button (Regtest/Devnet with RPC only) - if matches!( - self.app_context.network, - dash_sdk::dpp::dashcore::Network::Regtest - | dash_sdk::dpp::dashcore::Network::Devnet - ) && self.app_context.core_backend_mode() == CoreBackendMode::Rpc - && ui - .button( - RichText::new("Mine") - .color(DashColors::text_primary(dark_mode)) - .strong(), - ) - .clicked() - { - self.open_mine_dialog(); - } + let popup_id = ui.make_persistent_id("dev_tools_popup"); + egui::Popup::from_toggle_button_response(&dev_tools_response) + .id(popup_id) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .frame( + egui::Frame::popup(ui.style()).fill(DashColors::popup_fill(dark_mode)), + ) + .show(|ui| { + ui.set_min_width(160.0); + + // Get Test Dash (opens browser to faucet) + if matches!( + self.app_context.network, + dash_sdk::dpp::dashcore::Network::Testnet + ) && ui.button("Get Test Dash").clicked() + { + ui.ctx().open_url(egui::OpenUrl::new_tab( + "https://faucet.testnet.networks.dash.org/", + )); + } - // Refresh Mode selector - ui.label( - RichText::new("Refresh Mode:").color(DashColors::text_primary(dark_mode)), - ); - ComboBox::from_id_salt("refresh_mode_selector_dev_tools") - .selected_text(self.refresh_mode.label()) - .show_ui(ui, |ui| { - for mode in RefreshMode::all_modes() { - ui.selectable_value(&mut self.refresh_mode, *mode, mode.label()); + // Mine button (Regtest/Devnet with RPC only) + if matches!( + self.app_context.network, + dash_sdk::dpp::dashcore::Network::Regtest + | dash_sdk::dpp::dashcore::Network::Devnet + ) && self.app_context.core_backend_mode() == CoreBackendMode::Rpc + && ui + .button( + RichText::new("Mine") + .color(DashColors::text_primary(dark_mode)) + .strong(), + ) + .clicked() + { + self.open_mine_dialog(); } + + // Refresh Mode selector + ui.horizontal(|ui| { + ui.label( + RichText::new("Refresh Mode:") + .color(DashColors::text_primary(dark_mode)), + ); + ComboBox::from_id_salt("refresh_mode_selector_dev_tools") + .selected_text(self.refresh_mode.label()) + .show_ui(ui, |ui| { + for mode in RefreshMode::all_modes() { + ui.selectable_value( + &mut self.refresh_mode, + *mode, + mode.label(), + ); + } + }); + }); }); }); - }); - } + } + }); action } @@ -1682,7 +1694,8 @@ impl WalletsBalancesScreen { let dark_mode = ui.ctx().style().visuals.dark_mode; let core_balance = wallet.total_balance_duffs(); let platform_balance = Self::platform_balance_duffs(wallet); - let total = core_balance + platform_balance; + let shielded_balance = self.shielded_balance_duffs(&wallet.seed_hash()); + let total = core_balance + platform_balance + shielded_balance; // Total balance (always visible) ui.label( @@ -1706,6 +1719,8 @@ impl WalletsBalancesScreen { ui.label(format!("Core: {}", Self::format_dash(core_balance))); ui.label(" | "); ui.label(format!("Platform: {}", Self::format_dash(platform_balance))); + ui.label(" | "); + ui.label(format!("Shielded: {}", Self::format_dash(shielded_balance))); }); }); } From ce834551c018e57964153b9d56f5685191172f7c Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:32:50 +0100 Subject: [PATCH 061/147] fix(ui): rename transaction heading, add explorer link, merge zk-fixes - Rename "Transaction History" to "Dash Core Transaction History" - Add "View" button on transactions for Mainnet/Testnet (opens Insight explorer) - Merge zk-fixes (mine dialog, shielded refresh, wallet bug fixes, log level) Co-Authored-By: Claude Opus 4.6 --- src/ui/wallets/wallets_screen/mod.rs | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 1473d3647..4d39230ea 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -1466,6 +1466,27 @@ impl WalletsBalancesScreen { { let _ = copy_text_to_clipboard(&full_txid); } + // Show "View" button for networks with a public explorer + let explorer_base = match self.app_context.network { + dash_sdk::dpp::dashcore::Network::Mainnet => { + Some("https://insight.dash.org/insight/tx/") + } + dash_sdk::dpp::dashcore::Network::Testnet => Some( + "https://insight.testnet.networks.dash.org/insight/tx/", + ), + _ => None, + }; + if let Some(base_url) = explorer_base + && ui + .small_button("View") + .clickable_tooltip("View on block explorer") + .clicked() + { + ui.ctx().open_url(egui::OpenUrl::new_tab(format!( + "{}{}", + base_url, full_txid + ))); + } }); }); }); @@ -1821,11 +1842,11 @@ impl WalletsBalancesScreen { // --- 3. Action Buttons (Send, Receive, Dev Tools) --- action |= self.render_action_buttons(ui, ctx); - // --- 4. Transaction History (collapsible) --- + // --- 4. Dash Core Transaction History (collapsible) --- ui.add_space(10.0); ui.separator(); let tx_header = egui::CollapsingHeader::new( - RichText::new("Transaction History") + RichText::new("Dash Core Transaction History") .size(16.0) .color(DashColors::text_primary(dark_mode)), ) From 9c07f95b11ca46db5f686fc83c0dd4e9c8aa4532 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:50:39 +0100 Subject: [PATCH 062/147] fix(ui): trigger shielded sync on wallet refresh and wallet switch The wallet Refresh button now dispatches a SyncNotes task alongside the core wallet refresh when the shielded wallet has been initialized. Switching HD wallets also triggers a full refresh (core + shielded) on the next frame for unlocked wallets. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/wallets_screen/mod.rs | 70 ++++++++++++++++++++++------ 1 file changed, 57 insertions(+), 13 deletions(-) diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index b7b5f57f7..4704309c1 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -3,10 +3,11 @@ mod asset_locks; mod dialogs; mod single_key_view; -use crate::app::{AppAction, DesiredAppAction}; +use crate::app::{AppAction, BackendTasksExecutionMode, DesiredAppAction}; use crate::backend_task::BackendTask; use crate::backend_task::core::CoreTask; use crate::backend_task::error::TaskError; +use crate::backend_task::shielded::ShieldedTask; use crate::context::AppContext; use crate::context::connection_status::spv_phase_summary; use crate::model::amount::Amount; @@ -132,6 +133,8 @@ pub struct WalletsBalancesScreen { pending_core_wallet_options: Option>, /// Whether the pending Core wallet selection is for a single-key wallet pending_core_wallet_is_single_key: bool, + /// Whether a wallet switch should trigger a Core refresh on the next frame + pending_wallet_refresh_on_switch: bool, /// Whether we need to fire a ListCoreWallets backend task (set on CoreWalletNotConfigured error) pending_list_core_wallets: bool, /// Wallet hash pending the ListCoreWallets response @@ -237,6 +240,7 @@ impl WalletsBalancesScreen { pending_core_wallet_seed_hash: None, pending_core_wallet_options: None, pending_core_wallet_is_single_key: false, + pending_wallet_refresh_on_switch: false, pending_list_core_wallets: false, pending_list_wallet_hash: None, pending_list_is_single_key: false, @@ -344,6 +348,10 @@ impl WalletsBalancesScreen { if let Some(hash) = seed_hash { self.persist_selected_wallet_hash(Some(hash)); self.refresh_platform_sync_info_cache(&hash); + // Trigger a refresh on the next frame for the newly selected wallet + if self.app_context.core_backend_mode() == CoreBackendMode::Rpc { + self.pending_wallet_refresh_on_switch = true; + } } else { self.persist_selected_wallet_hash(None); self.platform_sync_info = None; @@ -1801,6 +1809,19 @@ impl WalletsBalancesScreen { } } + /// Returns a SyncNotes backend task if the shielded wallet has been initialized + /// for the given seed hash. + fn shielded_sync_task(&self, seed_hash: &WalletSeedHash) -> Option { + let states = self.app_context.shielded_states.lock().unwrap(); + if states.contains_key(seed_hash) { + Some(BackendTask::ShieldedTask(ShieldedTask::SyncNotes { + seed_hash: *seed_hash, + })) + } else { + None + } + } + /// Creates the appropriate refresh action based on the current refresh mode fn create_refresh_action(&self, wallet_arc: &Arc>) -> AppAction { self.create_refresh_action_for_mode(wallet_arc, self.refresh_mode) @@ -1822,29 +1843,33 @@ impl WalletsBalancesScreen { .map(|w| w.seed_hash()) .unwrap_or_default(); - match mode { + let core_task = match mode { RefreshMode::All => { // Core + Platform - AppAction::BackendTask(BackendTask::CoreTask(CoreTask::RefreshWalletInfo( - wallet_arc.clone(), - true, - ))) + BackendTask::CoreTask(CoreTask::RefreshWalletInfo(wallet_arc.clone(), true)) } RefreshMode::CoreOnly => { // Core only, no Platform sync - AppAction::BackendTask(BackendTask::CoreTask(CoreTask::RefreshWalletInfo( - wallet_arc.clone(), - false, - ))) + BackendTask::CoreTask(CoreTask::RefreshWalletInfo(wallet_arc.clone(), false)) } RefreshMode::PlatformOnly => { // Platform only - AppAction::BackendTask(BackendTask::WalletTask( + BackendTask::WalletTask( crate::backend_task::wallet::WalletTask::FetchPlatformAddressBalances { seed_hash, }, - )) + ) } + }; + + // Also trigger shielded note sync if initialized + if let Some(shielded_task) = self.shielded_sync_task(&seed_hash) { + AppAction::BackendTasks( + vec![core_task, shielded_task], + BackendTasksExecutionMode::Concurrent, + ) + } else { + AppAction::BackendTask(core_task) } } } @@ -1862,6 +1887,24 @@ impl ScreenLike for WalletsBalancesScreen { AppAction::None }; + // Trigger a wallet refresh after a wallet switch + let pending_switch_action = if self.pending_wallet_refresh_on_switch { + self.pending_wallet_refresh_on_switch = false; + if let Some(wallet_arc) = &self.selected_wallet { + let is_locked = wallet_arc.read().map(|w| !w.is_open()).unwrap_or(true); + if !is_locked { + self.refreshing = true; + self.create_refresh_action(wallet_arc) + } else { + AppAction::None + } + } else { + AppAction::None + } + } else { + AppAction::None + }; + let mut right_buttons = vec![ ( "Import Wallet", @@ -2332,8 +2375,9 @@ impl ScreenLike for WalletsBalancesScreen { } } - // Combine with pending refresh action + // Combine with pending actions action |= pending_refresh_action; + action |= pending_switch_action; action } From df78e4924d0103db5c907d0f2449fdc067b4a0c7 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:50:36 +0100 Subject: [PATCH 063/147] fix(log): add diagnostic logging for shielded transfer operations Add tracing::info! and tracing::debug! logs throughout the shielded transfer pipeline to enable post-mortem diagnosis when balances don't update after broadcast. Key additions: - bundle.rs: log amount, input notes, change before building each shielded operation (transfer, shield, unshield, withdrawal, shield-from-asset-lock); log broadcast success with note that balance updates after next block - sync.rs: warn when next_start_index is 0 (full rescan); log post-sync balance with unspent note count - context/shielded.rs: log note spend marking with before/after unspent counts in with_anchor_retry - shielded_send_screen.rs: log task result variant received and post-transfer sync completion Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend_task/shielded/bundle.rs | 95 +++++++++++++++++++++++++- src/backend_task/shielded/sync.rs | 20 ++++++ src/context/shielded.rs | 9 +++ src/ui/wallets/shielded_send_screen.rs | 15 ++++ 4 files changed, 136 insertions(+), 3 deletions(-) diff --git a/src/backend_task/shielded/bundle.rs b/src/backend_task/shielded/bundle.rs index 3f04fa5d1..5649481ea 100644 --- a/src/backend_task/shielded/bundle.rs +++ b/src/backend_task/shielded/bundle.rs @@ -1,6 +1,7 @@ use crate::backend_task::error::{TaskError, shielded_broadcast_error, shielded_build_error}; use crate::context::AppContext; use crate::context::shielded::get_proving_key; +use crate::model::fee_estimation::format_credits_as_dash; use crate::model::wallet::WalletSeedHash; use crate::model::wallet::shielded::ShieldedWalletState; use dash_sdk::dpp::address_funds::{ @@ -177,6 +178,13 @@ pub async fn shield_credits( let fee_strategy: AddressFundsFeeStrategy = vec![AddressFundsFeeStrategyStep::DeductFromInput(0)]; + tracing::info!( + "Shield credits: {} ({} credits), nonce={}, building proof...", + format_credits_as_dash(amount), + amount, + nonce, + ); + if let Some(s) = &stage { *s.lock().unwrap() = ShieldStage::BuildingProof { nonce }; } @@ -201,11 +209,18 @@ pub async fn shield_credits( *s.lock().unwrap() = ShieldStage::Broadcasting; } + tracing::debug!("Shield credits: state transition built, broadcasting..."); + state_transition .broadcast(&sdk, None) .await .map_err(shielded_broadcast_error)?; + tracing::info!( + "Shield credits broadcast succeeded: {} — balance will update after the next block is mined and notes are synced", + format_credits_as_dash(amount), + ); + Ok(()) } @@ -231,7 +246,19 @@ pub async fn shielded_transfer( let recipient_addr = OrchardAddress::from_raw_bytes(&recipient_bytes) .map_err(|_| TaskError::ShieldedInvalidRecipientAddress)?; - let (spendable_notes, _total_value) = select_notes_for_amount(shielded_state, amount)?; + let (spendable_notes, total_input_value) = select_notes_for_amount(shielded_state, amount)?; + let change_amount = total_input_value.saturating_sub(amount); + + tracing::info!( + "Shielded transfer: sending {} ({} credits), spending {} input note(s) totalling {} ({} credits), change: {} ({} credits)", + format_credits_as_dash(amount), + amount, + spendable_notes.len(), + format_credits_as_dash(total_input_value), + total_input_value, + format_credits_as_dash(change_amount), + change_amount, + ); let spent_nullifiers: Vec = spendable_notes.iter().map(|n| n.nullifier).collect(); @@ -280,11 +307,19 @@ pub async fn shielded_transfer( ) .map_err(|e| shielded_build_error(e.to_string()))?; + tracing::debug!("Shielded transfer: state transition built, broadcasting..."); + state_transition .broadcast(&sdk, None) .await .map_err(shielded_broadcast_error)?; + tracing::info!( + "Shielded transfer broadcast succeeded: {} nullifiers created, change={} — balance will update after the next block is mined and notes are synced", + spent_nullifiers.len(), + change_amount > 0, + ); + Ok(spent_nullifiers) } @@ -304,7 +339,19 @@ pub async fn unshield_credits( key: get_proving_key(), }; - let (spendable_notes, _total_value) = select_notes_for_amount(shielded_state, amount)?; + let (spendable_notes, total_input_value) = select_notes_for_amount(shielded_state, amount)?; + let change_amount = total_input_value.saturating_sub(amount); + + tracing::info!( + "Unshield credits: {} ({} credits), spending {} input note(s) totalling {} ({} credits), change: {} ({} credits)", + format_credits_as_dash(amount), + amount, + spendable_notes.len(), + format_credits_as_dash(total_input_value), + total_input_value, + format_credits_as_dash(change_amount), + change_amount, + ); let spent_nullifiers: Vec = spendable_notes.iter().map(|n| n.nullifier).collect(); @@ -353,11 +400,19 @@ pub async fn unshield_credits( ) .map_err(|e| shielded_build_error(e.to_string()))?; + tracing::debug!("Unshield credits: state transition built, broadcasting..."); + state_transition .broadcast(&sdk, None) .await .map_err(shielded_broadcast_error)?; + tracing::info!( + "Unshield credits broadcast succeeded: {} nullifiers created, change={} — balance will update after the next block is mined and notes are synced", + spent_nullifiers.len(), + change_amount > 0, + ); + Ok(spent_nullifiers) } @@ -534,6 +589,12 @@ pub async fn shield_from_asset_lock( credits_per_duff: CREDITS_PER_DUFF, })?; + tracing::info!( + "Shield from asset lock: building state transition for {} ({} credits)", + format_credits_as_dash(shield_amount_credits), + shield_amount_credits, + ); + let state_transition = build_shield_from_asset_lock_transition( &recipient, shield_amount_credits, @@ -545,11 +606,18 @@ pub async fn shield_from_asset_lock( ) .map_err(|e| shielded_build_error(e.to_string()))?; + tracing::debug!("Shield from asset lock: state transition built, broadcasting..."); + state_transition .broadcast(&sdk, None) .await .map_err(shielded_broadcast_error)?; + tracing::info!( + "Shield from asset lock broadcast succeeded: {} — balance will update after the next block is mined and notes are synced", + format_credits_as_dash(shield_amount_credits), + ); + Ok(shield_amount_credits) } @@ -571,7 +639,20 @@ pub async fn shielded_withdrawal( let output_script = CoreScript::from_bytes(to_core_address.script_pubkey().to_bytes()); - let (spendable_notes, _total_value) = select_notes_for_amount(shielded_state, amount)?; + let (spendable_notes, total_input_value) = select_notes_for_amount(shielded_state, amount)?; + let change_amount = total_input_value.saturating_sub(amount); + + tracing::info!( + "Shielded withdrawal: {} ({} credits) to core address, spending {} input note(s) totalling {} ({} credits), change: {} ({} credits)", + format_credits_as_dash(amount), + amount, + spendable_notes.len(), + format_credits_as_dash(total_input_value), + total_input_value, + format_credits_as_dash(change_amount), + change_amount, + ); + let spent_nullifiers: Vec = spendable_notes.iter().map(|n| n.nullifier).collect(); let (spends, anchor) = { @@ -621,11 +702,19 @@ pub async fn shielded_withdrawal( ) .map_err(|e| shielded_build_error(e.to_string()))?; + tracing::debug!("Shielded withdrawal: state transition built, broadcasting..."); + state_transition .broadcast(&sdk, None) .await .map_err(shielded_broadcast_error)?; + tracing::info!( + "Shielded withdrawal broadcast succeeded: {} nullifiers created, change={} — balance will update after the next block is mined and notes are synced", + spent_nullifiers.len(), + change_amount > 0, + ); + Ok(spent_nullifiers) } diff --git a/src/backend_task/shielded/sync.rs b/src/backend_task/shielded/sync.rs index 5f39a8330..b63661d64 100644 --- a/src/backend_task/shielded/sync.rs +++ b/src/backend_task/shielded/sync.rs @@ -1,5 +1,6 @@ use crate::backend_task::error::TaskError; use crate::context::AppContext; +use crate::model::fee_estimation::format_credits_as_dash; use crate::model::wallet::WalletSeedHash; use crate::model::wallet::shielded::{ShieldedNote, ShieldedWalletState}; use dash_sdk::dpp::dashcore::Network; @@ -49,6 +50,13 @@ pub async fn sync_notes( result.next_start_index, ); + if result.next_start_index == 0 && result.total_notes_scanned > 0 { + tracing::warn!( + "Shielded sync: next_start_index is 0 after scanning {} notes — next sync will rescan everything from the beginning", + result.total_notes_scanned, + ); + } + // Append notes to the local commitment tree, skipping positions already present. // all_notes is ordered: aligned_start + i == global position of all_notes[i]. let mut appended = 0u32; @@ -152,5 +160,17 @@ pub async fn sync_notes( shielded_state.last_synced_index = aligned_start + result.total_notes_scanned; shielded_state.recalculate_balance(); + let notes_total = shielded_state.notes.len(); + let notes_unspent = shielded_state.unspent_notes().len(); + + tracing::info!( + "Shielded sync finished: {} new note(s), total notes={} (unspent={}), spendable balance: {} ({} credits)", + new_note_count, + notes_total, + notes_unspent, + format_credits_as_dash(shielded_state.shielded_balance), + shielded_state.shielded_balance, + ); + Ok((new_note_count, shielded_state.shielded_balance)) } diff --git a/src/context/shielded.rs b/src/context/shielded.rs index ac14f3299..fa3e643a7 100644 --- a/src/context/shielded.rs +++ b/src/context/shielded.rs @@ -458,7 +458,16 @@ impl AppContext { }; if let Ok(ref spent_nullifiers) = result { + let notes_before = state.unspent_notes().len(); self.mark_notes_spent(seed_hash, &mut state, spent_nullifiers); + let notes_after = state.unspent_notes().len(); + tracing::info!( + "Shielded {operation_name}: marked {} note(s) spent (unspent notes: {} -> {}), new balance: {} credits", + spent_nullifiers.len(), + notes_before, + notes_after, + state.shielded_balance, + ); } { diff --git a/src/ui/wallets/shielded_send_screen.rs b/src/ui/wallets/shielded_send_screen.rs index b6ac92a59..a212c12d3 100644 --- a/src/ui/wallets/shielded_send_screen.rs +++ b/src/ui/wallets/shielded_send_screen.rs @@ -209,6 +209,10 @@ impl ScreenLike for ShieldedSendScreen { BackendTaskSuccessResult::ShieldedTransferComplete { seed_hash, amount } if seed_hash == self.seed_hash => { + tracing::info!( + "ShieldedSendScreen: transfer complete, amount={} credits, queueing post-transfer note sync", + amount, + ); self.status = Status::Complete; let dash = amount as f64 / CREDITS_PER_DUFF as f64 / 1e8; self.success_message = @@ -218,6 +222,17 @@ impl ScreenLike for ShieldedSendScreen { seed_hash: self.seed_hash, })); } + BackendTaskSuccessResult::ShieldedNotesSynced { + seed_hash, + new_notes, + balance, + } if seed_hash == self.seed_hash => { + tracing::info!( + "ShieldedSendScreen: post-transfer sync complete, new_notes={}, balance={} credits", + new_notes, + balance, + ); + } _ => {} } } From 7e11a2bca7ee18a1f2fc335d8ea81b8b251d3756 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:53:44 +0100 Subject: [PATCH 064/147] fix(ui): improve Dev Tools dropdown layout and refresh mode cycling Right-align popup content to match the Dev Tools button position. Replace ComboBox with a cycle-on-click button to avoid nested popup conflicts in egui, where the inner ComboBox dropdown would close the parent popup. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/wallets_screen/mod.rs | 96 +++++++++++++--------------- 1 file changed, 46 insertions(+), 50 deletions(-) diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 4d39230ea..3dea365c9 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -85,12 +85,12 @@ impl RefreshMode { } } - fn all_modes() -> &'static [RefreshMode] { - &[ - RefreshMode::All, - RefreshMode::CoreOnly, - RefreshMode::PlatformOnly, - ] + fn next(self) -> Self { + match self { + RefreshMode::All => RefreshMode::CoreOnly, + RefreshMode::CoreOnly => RefreshMode::PlatformOnly, + RefreshMode::PlatformOnly => RefreshMode::All, + } } } @@ -1071,52 +1071,48 @@ impl WalletsBalancesScreen { ) .show(|ui| { ui.set_min_width(160.0); + ui.with_layout(egui::Layout::top_down(egui::Align::RIGHT), |ui| { + // Get Test Dash (opens browser to faucet) + if matches!( + self.app_context.network, + dash_sdk::dpp::dashcore::Network::Testnet + ) && ui.button("Get Test Dash").clicked() + { + ui.ctx().open_url(egui::OpenUrl::new_tab( + "https://faucet.testnet.networks.dash.org/", + )); + } - // Get Test Dash (opens browser to faucet) - if matches!( - self.app_context.network, - dash_sdk::dpp::dashcore::Network::Testnet - ) && ui.button("Get Test Dash").clicked() - { - ui.ctx().open_url(egui::OpenUrl::new_tab( - "https://faucet.testnet.networks.dash.org/", - )); - } - - // Mine button (Regtest/Devnet with RPC only) - if matches!( - self.app_context.network, - dash_sdk::dpp::dashcore::Network::Regtest - | dash_sdk::dpp::dashcore::Network::Devnet - ) && self.app_context.core_backend_mode() == CoreBackendMode::Rpc - && ui - .button( - RichText::new("Mine") - .color(DashColors::text_primary(dark_mode)) - .strong(), - ) - .clicked() - { - self.open_mine_dialog(); - } + // Mine button (Regtest/Devnet with RPC only) + if matches!( + self.app_context.network, + dash_sdk::dpp::dashcore::Network::Regtest + | dash_sdk::dpp::dashcore::Network::Devnet + ) && self.app_context.core_backend_mode() == CoreBackendMode::Rpc + && ui + .button( + RichText::new("Mine") + .color(DashColors::text_primary(dark_mode)) + .strong(), + ) + .clicked() + { + self.open_mine_dialog(); + } - // Refresh Mode selector - ui.horizontal(|ui| { - ui.label( - RichText::new("Refresh Mode:") - .color(DashColors::text_primary(dark_mode)), - ); - ComboBox::from_id_salt("refresh_mode_selector_dev_tools") - .selected_text(self.refresh_mode.label()) - .show_ui(ui, |ui| { - for mode in RefreshMode::all_modes() { - ui.selectable_value( - &mut self.refresh_mode, - *mode, - mode.label(), - ); - } - }); + // Refresh Mode cycle button + ui.horizontal(|ui| { + ui.label( + RichText::new("Refresh Mode:") + .color(DashColors::text_primary(dark_mode)), + ); + if ui + .button(format!("{} \u{25B6}", self.refresh_mode.label())) + .clicked() + { + self.refresh_mode = self.refresh_mode.next(); + } + }); }); }); }); From e8eccc8bc65bc9ab31a6d6af8be866f2e56819bc Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:01:20 +0100 Subject: [PATCH 065/147] fix(ui): fix shielded balance conversion, add tab balances, reorder sync status - Fix shielded balance inflating total: divide credits by CREDITS_PER_DUFF before summing with duffs-denominated balances (was showing 5000 DASH instead of 5 DASH) - Show balances in account tab labels: Dash Core (0.9980) | Platform (0.1000) | Shielded (5.0000), with (empty) for zero-balance tabs - Move Sync Status section inside detail panel between balance breakdown and action buttons for better visual flow Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/wallets_screen/mod.rs | 75 ++++++++++++++++++++++------ 1 file changed, 61 insertions(+), 14 deletions(-) diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 3dea365c9..cddc0f868 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -1008,6 +1008,7 @@ impl WalletsBalancesScreen { .ok() .and_then(|states| states.get(seed_hash).map(|s| s.shielded_balance)) .unwrap_or(0) + / CREDITS_PER_DUFF } fn render_action_buttons(&mut self, ui: &mut Ui, ctx: &Context) -> AppAction { @@ -1195,15 +1196,64 @@ impl WalletsBalancesScreen { // Tab bar ui.horizontal_wrapped(|ui| { for tab in &tabs { - let label = match tab { - AccountTab::Category(cat, idx) => cat.tab_label(*idx), - AccountTab::Shielded => "Shielded", + let (base_label, balance_duffs) = match tab { + AccountTab::Category(cat, idx) => { + let balance = if matches!( + cat, + AccountCategory::ProviderVoting + | AccountCategory::ProviderOwner + | AccountCategory::ProviderOperator + | AccountCategory::ProviderPlatform + ) { + // Provider tab groups all provider categories + summaries + .iter() + .filter(|s| { + matches!( + s.category, + AccountCategory::ProviderVoting + | AccountCategory::ProviderOwner + | AccountCategory::ProviderOperator + | AccountCategory::ProviderPlatform + ) + }) + .map(|s| s.confirmed_balance) + .sum::() + } else if matches!(cat, AccountCategory::PlatformPayment) { + summaries + .iter() + .filter(|s| s.category == *cat && s.index == *idx) + .map(|s| s.platform_credits / CREDITS_PER_DUFF) + .sum::() + } else { + summaries + .iter() + .filter(|s| s.category == *cat && s.index == *idx) + .map(|s| s.confirmed_balance) + .sum::() + }; + (cat.tab_label(*idx).to_string(), balance) + } + AccountTab::Shielded => { + let balance = self + .selected_wallet + .as_ref() + .and_then(|w| w.read().ok()) + .map(|g| self.shielded_balance_duffs(&g.seed_hash())) + .unwrap_or(0); + ("Shielded".to_string(), balance) + } + }; + let label = if balance_duffs == 0 { + format!("{} (empty)", base_label) + } else { + format!("{} ({})", base_label, Self::format_dash(balance_duffs)) }; let is_selected = &self.selected_account_tab == tab; let text = if is_selected { - RichText::new(label).strong().color(DashColors::DASH_BLUE) + RichText::new(&label).strong().color(DashColors::DASH_BLUE) } else { - RichText::new(label).color(DashColors::text_secondary(dark_mode)) + RichText::new(&label).color(DashColors::text_secondary(dark_mode)) }; if ui.selectable_label(is_selected, text).clicked() { self.selected_account_tab = tab.clone(); @@ -1835,10 +1885,13 @@ impl WalletsBalancesScreen { self.render_balance_breakdown(ui, &wallet); } - // --- 3. Action Buttons (Send, Receive, Dev Tools) --- + // --- 3. Sync Status (collapsible) --- + self.render_sync_status(ui); + + // --- 4. Action Buttons (Send, Receive, Dev Tools) --- action |= self.render_action_buttons(ui, ctx); - // --- 4. Dash Core Transaction History (collapsible) --- + // --- 5. Dash Core Transaction History (collapsible) --- ui.add_space(10.0); ui.separator(); let tx_header = egui::CollapsingHeader::new( @@ -1852,7 +1905,7 @@ impl WalletsBalancesScreen { self.render_transactions_section(ui); }); - // --- 5. Accounts & Addresses (tabs) --- + // --- 6. Accounts & Addresses (tabs) --- ui.add_space(10.0); ui.separator(); @@ -2062,12 +2115,6 @@ impl ScreenLike for WalletsBalancesScreen { ui.add_space(10.0); - // Sync status panel (only for HD wallets) - if self.selected_wallet.is_some() { - self.render_sync_status(ui); - ui.add_space(6.0); - } - // Render the appropriate detail view based on selection if self.selected_wallet.is_some() { inner_action |= self.render_wallet_detail_panel(ui, ctx); From 9790095c0ca368147445c42dadcdad25d2ad3e14 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:07:54 +0100 Subject: [PATCH 066/147] feat(ui): collapsible sections, move tx history to Dash Core tab, restore account types - Wrap Addresses, Transaction History, Asset Locks, and Shielded Notes in collapsible headers (default open, tx history default closed) - Move Dash Core Transaction History from top-level into the Dash Core tab, between Addresses and Asset Locks - In developer mode, show ALL account category tabs (Bip44, Platform, Bip32, CoinJoin, Identity Registration/System/Top-up/Invitation, Provider) even when no addresses exist for that type - Add TODO for shielded tab layout redesign Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/shielded_tab.rs | 223 ++++++++++++++------------- src/ui/wallets/wallets_screen/mod.rs | 172 +++++++++++++-------- 2 files changed, 226 insertions(+), 169 deletions(-) diff --git a/src/ui/wallets/shielded_tab.rs b/src/ui/wallets/shielded_tab.rs index c7631c1b0..d732b251f 100644 --- a/src/ui/wallets/shielded_tab.rs +++ b/src/ui/wallets/shielded_tab.rs @@ -166,6 +166,13 @@ impl ShieldedTabView { self.error_message = Some(error.to_string()); } + // TODO: Redesign shielded tab layout for visual consistency with other tabs: + // 1. Action buttons row at top: Shield, Shield from Core, Transfer, Unshield + // 2. Shielded Addresses (collapsible) — diversified addresses in a table + // 3. Shielded Notes (collapsible) — notes table (index, value, spent/unspent) + // Currently the layout is: balance card -> address card -> buttons -> notes list. + // The redesign should move buttons to the top and use collapsible sections. + /// Render the shielded tab content. pub fn ui(&mut self, ui: &mut Ui) -> AppAction { let dark_mode = ui.ctx().style().visuals.dark_mode; @@ -494,121 +501,121 @@ impl ShieldedTabView { .unwrap_or_default() }; - ui.horizontal(|ui| { - ui.label( - RichText::new("Shielded Notes") - .size(16.0) - .color(DashColors::text_primary(dark_mode)), - ); - - if !notes_info.is_empty() { - ui.label( - RichText::new(format!( - "(synced to index {}, {} our notes)", - synced_index, - notes_info.len() - )) - .size(12.0) - .color(DashColors::text_secondary(dark_mode)), - ); - } - - // Sync status indicator - if self.syncing { - ui.add(egui::Spinner::new().color(DashColors::DASH_BLUE)); - ui.label( - RichText::new("Syncing...") - .size(12.0) - .color(DashColors::DASH_BLUE), - ); - } else if self.tree_synced { - ui.label( - RichText::new("Synced") - .size(12.0) - .color(Color32::DARK_GREEN), - ); - } - - // Sync buttons - if !self.syncing { - if ui.small_button("Sync Notes").clicked() { - self.syncing = true; - self.success_message = None; - self.error_message = None; - action |= AppAction::BackendTask(BackendTask::ShieldedTask( - ShieldedTask::SyncNotes { - seed_hash: self.seed_hash, - }, - )); + // Shielded Notes (collapsible) + let notes_label = if notes_info.is_empty() { + "Shielded Notes".to_string() + } else { + format!( + "Shielded Notes (synced to index {}, {} notes)", + synced_index, + notes_info.len() + ) + }; + let notes_header = egui::CollapsingHeader::new( + RichText::new(notes_label) + .size(16.0) + .color(DashColors::text_primary(dark_mode)), + ) + .id_salt("shielded_notes") + .default_open(true); + notes_header.show(ui, |ui| { + ui.horizontal(|ui| { + // Sync status indicator + if self.syncing { + ui.add(egui::Spinner::new().color(DashColors::DASH_BLUE)); + ui.label( + RichText::new("Syncing...") + .size(12.0) + .color(DashColors::DASH_BLUE), + ); + } else if self.tree_synced { + ui.label( + RichText::new("Synced") + .size(12.0) + .color(Color32::DARK_GREEN), + ); } - if self.app_context.is_developer_mode() && ui.small_button("Resync Notes").clicked() - { - // Remove in-memory state entirely (will be recreated by init) + // Sync buttons + if !self.syncing { + if ui.small_button("Sync Notes").clicked() { + self.syncing = true; + self.success_message = None; + self.error_message = None; + action |= AppAction::BackendTask(BackendTask::ShieldedTask( + ShieldedTask::SyncNotes { + seed_hash: self.seed_hash, + }, + )); + } + + if self.app_context.is_developer_mode() + && ui.small_button("Resync Notes").clicked() { - let mut states = self.app_context.shielded_states.lock().unwrap(); - states.remove(&self.seed_hash); + { + let mut states = self.app_context.shielded_states.lock().unwrap(); + states.remove(&self.seed_hash); + } + let network_str = self.app_context.network.to_string(); + let _ = self + .app_context + .db + .delete_shielded_notes(&self.seed_hash, &network_str); + let _ = self.app_context.db.clear_commitment_tree_tables(); + + self.shielded_balance = 0; + self.tree_synced = false; + self.is_initialized = false; + self.initializing = true; + self.syncing = false; + self.success_message = None; + self.error_message = None; + action |= AppAction::BackendTask(BackendTask::ShieldedTask( + ShieldedTask::InitializeShieldedWallet { + seed_hash: self.seed_hash, + }, + )); } - // Clear persisted notes and commitment tree data - let network_str = self.app_context.network.to_string(); - let _ = self - .app_context - .db - .delete_shielded_notes(&self.seed_hash, &network_str); - let _ = self.app_context.db.clear_commitment_tree_tables(); - - self.shielded_balance = 0; - self.tree_synced = false; - self.is_initialized = false; - self.initializing = true; - self.syncing = false; - self.success_message = None; - self.error_message = None; - // Re-initialize (creates fresh persistent tree) then auto-syncs - action |= AppAction::BackendTask(BackendTask::ShieldedTask( - ShieldedTask::InitializeShieldedWallet { - seed_hash: self.seed_hash, - }, - )); } - } - }); - ui.add_space(5.0); + }); + ui.add_space(5.0); - if !notes_info.is_empty() { - egui::Grid::new("shielded_notes_grid") - .num_columns(3) - .striped(true) - .spacing([20.0, 4.0]) - .show(ui, |ui| { - ui.label(RichText::new("Value").strong()); - ui.label(RichText::new("Block").strong()); - ui.label(RichText::new("Status").strong()); - ui.end_row(); - - for (value, height, is_spent) in ¬es_info { - ui.label(format_credits(*value)); - ui.label(if *height > 0 { - height.to_string() - } else { - "-".to_string() - }); - if *is_spent { - ui.label( - RichText::new("Spent").color(DashColors::text_secondary(dark_mode)), - ); - } else { - ui.label(RichText::new("Unspent").color(Color32::DARK_GREEN)); - } + if !notes_info.is_empty() { + egui::Grid::new("shielded_notes_grid") + .num_columns(3) + .striped(true) + .spacing([20.0, 4.0]) + .show(ui, |ui| { + ui.label(RichText::new("Value").strong()); + ui.label(RichText::new("Block").strong()); + ui.label(RichText::new("Status").strong()); ui.end_row(); - } - }); - } else if !self.syncing { - ui.label( - RichText::new("No shielded notes yet. Shield some credits to get started.") - .color(DashColors::text_secondary(dark_mode)), - ); - } + + for (value, height, is_spent) in ¬es_info { + ui.label(format_credits(*value)); + ui.label(if *height > 0 { + height.to_string() + } else { + "-".to_string() + }); + if *is_spent { + ui.label( + RichText::new("Spent") + .color(DashColors::text_secondary(dark_mode)), + ); + } else { + ui.label(RichText::new("Unspent").color(Color32::DARK_GREEN)); + } + ui.end_row(); + } + }); + } else if !self.syncing { + ui.label( + RichText::new("No shielded notes yet. Shield some credits to get started.") + .color(DashColors::text_secondary(dark_mode)), + ); + } + }); action } diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index cddc0f868..9f46e3209 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -1127,42 +1127,79 @@ impl WalletsBalancesScreen { fn build_account_tabs(&self, summaries: &[AccountSummary]) -> Vec { let developer_mode = self.app_context.is_developer_mode(); let mut tabs: Vec = Vec::new(); - // Track which provider categories we have seen (they get grouped into one tab) let mut has_provider = false; - for summary in summaries { - let visible = if developer_mode { - true - } else { - summary.category.is_visible_in_default_mode() && summary.index == Some(0) - || summary.category == AccountCategory::PlatformPayment - }; - if !visible { - continue; + if developer_mode { + // In developer mode, show ALL account categories as tabs — even those + // without addresses — so developers can inspect every derivation path. + let all_categories = [ + AccountCategory::Bip44, + AccountCategory::PlatformPayment, + AccountCategory::Bip32, + AccountCategory::CoinJoin, + AccountCategory::IdentityRegistration, + AccountCategory::IdentitySystem, + AccountCategory::IdentityTopup, + AccountCategory::IdentityInvitation, + AccountCategory::ProviderOwner, + AccountCategory::ProviderVoting, + AccountCategory::ProviderOperator, + AccountCategory::ProviderPlatform, + ]; + + for cat in &all_categories { + let is_provider = matches!( + cat, + AccountCategory::ProviderVoting + | AccountCategory::ProviderOwner + | AccountCategory::ProviderOperator + | AccountCategory::ProviderPlatform + ); + if is_provider { + if has_provider { + continue; + } + has_provider = true; + } + + // Use the index from the summary if available, otherwise None + let idx = summaries + .iter() + .find(|s| &s.category == cat) + .and_then(|s| s.index); + + // For Bip44, default to index 0 (Dash Core) + let idx = match cat { + AccountCategory::Bip44 => idx.or(Some(0)), + _ => idx, + }; + + tabs.push(AccountTab::Category(cat.clone(), idx)); } - // Group all Provider* categories into a single tab - let is_provider = matches!( - summary.category, - AccountCategory::ProviderVoting - | AccountCategory::ProviderOwner - | AccountCategory::ProviderOperator - | AccountCategory::ProviderPlatform - ); - if is_provider { - if has_provider { + // Also add any summary-only categories not in the fixed list (e.g. Other) + for summary in summaries { + let tab = AccountTab::Category(summary.category.clone(), summary.index); + if !tabs.contains(&tab) { + tabs.push(tab); + } + } + } else { + for summary in summaries { + let visible = summary.category.is_visible_in_default_mode() + && summary.index == Some(0) + || summary.category == AccountCategory::PlatformPayment; + if !visible { continue; } - has_provider = true; + tabs.push(AccountTab::Category( + summary.category.clone(), + summary.index, + )); } - - tabs.push(AccountTab::Category( - summary.category.clone(), - summary.index, - )); } - // Always add the Shielded tab (after Dash Core and Platform) + // Always add the Shielded tab (after account categories) tabs.push(AccountTab::Shielded); tabs @@ -1312,29 +1349,56 @@ impl WalletsBalancesScreen { self.selected_account = Some((cat.clone(), *idx)); } - // Addresses heading with zero-balance filter - ui.horizontal(|ui| { - let addresses_heading = format!("Addresses ({})", cat.label(*idx)); - ui.heading( - RichText::new(addresses_heading).color(DashColors::text_primary(dark_mode)), - ); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.checkbox( - &mut self.show_zero_balance_addresses, - "Show zero-balance addresses", - ); + // Addresses (collapsible) + let addresses_heading = format!("Addresses ({})", cat.label(*idx)); + let addr_header = egui::CollapsingHeader::new( + RichText::new(addresses_heading) + .size(16.0) + .color(DashColors::text_primary(dark_mode)), + ) + .id_salt(format!("addresses_{}_{:?}", cat.tab_label(*idx), idx)) + .default_open(true); + addr_header.show(ui, |ui| { + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.checkbox( + &mut self.show_zero_balance_addresses, + "Show zero-balance addresses", + ); + }); }); + ui.add_space(4.0); + action |= self.render_address_table(ui); + self.render_bottom_options(ui); }); - ui.add_space(8.0); - action |= self.render_address_table(ui); - // Bottom options (Add Receiving Address for Dash Core tab) - self.render_bottom_options(ui); - - // Asset Locks section — only on the Dash Core tab + // Dash Core tab: transaction history + asset locks if *cat == AccountCategory::Bip44 && *idx == Some(0) { - ui.add_space(16.0); - action |= self.render_wallet_asset_locks(ui); + // Transaction History (collapsible) + ui.add_space(10.0); + let tx_header = egui::CollapsingHeader::new( + RichText::new("Transaction History") + .size(16.0) + .color(DashColors::text_primary(dark_mode)), + ) + .id_salt("transaction_history") + .default_open(false); + tx_header.show(ui, |ui| { + self.render_transactions_section(ui); + }); + + // Asset Locks (collapsible) + ui.add_space(10.0); + let locks_header = egui::CollapsingHeader::new( + RichText::new("Asset Locks") + .size(16.0) + .color(DashColors::text_primary(dark_mode)), + ) + .id_salt("asset_locks") + .default_open(true); + locks_header.show(ui, |ui| { + action |= self.render_wallet_asset_locks(ui); + }); } } } @@ -1891,21 +1955,7 @@ impl WalletsBalancesScreen { // --- 4. Action Buttons (Send, Receive, Dev Tools) --- action |= self.render_action_buttons(ui, ctx); - // --- 5. Dash Core Transaction History (collapsible) --- - ui.add_space(10.0); - ui.separator(); - let tx_header = egui::CollapsingHeader::new( - RichText::new("Dash Core Transaction History") - .size(16.0) - .color(DashColors::text_primary(dark_mode)), - ) - .id_salt("transaction_history") - .default_open(false); - tx_header.show(ui, |ui| { - self.render_transactions_section(ui); - }); - - // --- 6. Accounts & Addresses (tabs) --- + // --- 5. Accounts & Addresses (tabs) --- ui.add_space(10.0); ui.separator(); From ed196d6b24b807e3f8274a614507087076b5447b Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:21:53 +0100 Subject: [PATCH 067/147] fix(ui): improve shielded transfer UX messaging for balance update delays After a shielded transfer, the sender's change notes and the recipient's new notes won't appear until the next block is mined and synced. Users see their balance temporarily drop to 0 and think the transfer failed. Add informational messages on all three shielded transfer screens (ShieldedSendScreen, UnshieldCreditsScreen, WalletSendScreen) explaining that balances will update after the next block is confirmed. For shielded-to-shielded transfers, also note that the recipient needs to sync after the next block. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/send_screen.rs | 7 +++++-- src/ui/wallets/shielded_send_screen.rs | 11 +++++++++++ src/ui/wallets/unshield_credits_screen.rs | 11 +++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index 534747d0d..71b795ca4 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -2861,7 +2861,9 @@ impl ScreenLike for WalletSendScreen { amount, } => { self.send_status = SendStatus::Complete(format!( - "Shielded transfer of {} complete!", + "Shielded transfer of {} complete!\n\n\ + Your remaining balance will update after the next block is confirmed. \ + The recipient's balance will also update after the next block and a wallet sync.", format_credits_as_dash(amount) )); self.pending_refresh_task = Some(crate::backend_task::BackendTask::ShieldedTask( @@ -2873,7 +2875,8 @@ impl ScreenLike for WalletSendScreen { amount, } => { self.send_status = SendStatus::Complete(format!( - "Unshielded {} to platform address!", + "Unshielded {} to platform address!\n\n\ + Your remaining balance will update after the next block is confirmed.", format_credits_as_dash(amount) )); self.pending_refresh_task = Some(crate::backend_task::BackendTask::ShieldedTask( diff --git a/src/ui/wallets/shielded_send_screen.rs b/src/ui/wallets/shielded_send_screen.rs index a212c12d3..2a95ae47e 100644 --- a/src/ui/wallets/shielded_send_screen.rs +++ b/src/ui/wallets/shielded_send_screen.rs @@ -35,6 +35,8 @@ pub struct ShieldedSendScreen { success_message: Option, /// Queued task to dispatch on next frame (e.g., sync notes after successful send). pending_refresh_task: Option, + /// Whether to show the balance-update-pending info banner on the success screen. + balance_update_pending: bool, } impl ShieldedSendScreen { @@ -58,6 +60,7 @@ impl ShieldedSendScreen { error_message: None, success_message: None, pending_refresh_task: None, + balance_update_pending: false, } } @@ -125,6 +128,13 @@ impl ScreenLike for ShieldedSendScreen { } if let Some(msg) = &self.success_message { ui.colored_label(Color32::DARK_GREEN, msg); + if self.balance_update_pending { + ui.add_space(8.0); + ui.label( + "Your remaining balance will update after the next block is confirmed. \ + The recipient's balance will also update after the next block and a wallet sync.", + ); + } ui.add_space(10.0); if ui.button("Done").clicked() { action = AppAction::PopScreen; @@ -221,6 +231,7 @@ impl ScreenLike for ShieldedSendScreen { Some(BackendTask::ShieldedTask(ShieldedTask::SyncNotes { seed_hash: self.seed_hash, })); + self.balance_update_pending = true; } BackendTaskSuccessResult::ShieldedNotesSynced { seed_hash, diff --git a/src/ui/wallets/unshield_credits_screen.rs b/src/ui/wallets/unshield_credits_screen.rs index 90f8a6a16..cf61de6b0 100644 --- a/src/ui/wallets/unshield_credits_screen.rs +++ b/src/ui/wallets/unshield_credits_screen.rs @@ -38,6 +38,8 @@ pub struct UnshieldCreditsScreen { success_message: Option, /// Queued task to dispatch on next frame (e.g., sync notes after successful unshield). pending_refresh_task: Option, + /// Whether to show the balance-update-pending info on the success screen. + balance_update_pending: bool, } impl UnshieldCreditsScreen { @@ -62,6 +64,7 @@ impl UnshieldCreditsScreen { error_message: None, success_message: None, pending_refresh_task: None, + balance_update_pending: false, } } } @@ -112,6 +115,12 @@ impl ScreenLike for UnshieldCreditsScreen { } if let Some(msg) = &self.success_message { ui.colored_label(Color32::DARK_GREEN, msg); + if self.balance_update_pending { + ui.add_space(8.0); + ui.label( + "Your remaining balance will update after the next block is confirmed.", + ); + } ui.add_space(10.0); if ui.button("Done").clicked() { action = AppAction::PopScreen; @@ -249,6 +258,7 @@ impl ScreenLike for UnshieldCreditsScreen { Some(BackendTask::ShieldedTask(ShieldedTask::SyncNotes { seed_hash: self.seed_hash, })); + self.balance_update_pending = true; } BackendTaskSuccessResult::ShieldedWithdrawalComplete { seed_hash, amount } if seed_hash == self.seed_hash => @@ -263,6 +273,7 @@ impl ScreenLike for UnshieldCreditsScreen { Some(BackendTask::ShieldedTask(ShieldedTask::SyncNotes { seed_hash: self.seed_hash, })); + self.balance_update_pending = true; } _ => {} } From 73f2f3b2d21508f742d6d9d3cdc640b239ed730b Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:29:01 +0100 Subject: [PATCH 068/147] feat(ui): consolidate dev-mode accounts into System tab, limit balance decimals Replace all individual dev-mode-only account type tabs (Identity Registration, Identity System, Identity Top-up, Identity Invitation, CoinJoin, Provider, Legacy BIP32) with a single "System" tab. Inside the System tab, each account type appears as a collapsible section (collapsed by default) showing address count and balance. Tab order: Dash Core | Platform | Shielded | System (dev-mode only, always last) Additional changes: - Limit tab balance display to max 4 decimal places (was 8) - Rename "Dev Tools" button to "Tools" - Simplify refresh mode button: single button with "Refresh mode: X" text, no separate label or arrow indicator - Remove "Accounts & Addresses" section heading - Style active tab with underline for visual distinction Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/account_summary.rs | 9 + src/ui/wallets/wallets_screen/mod.rs | 313 ++++++++++++++++----------- 2 files changed, 198 insertions(+), 124 deletions(-) diff --git a/src/ui/wallets/account_summary.rs b/src/ui/wallets/account_summary.rs index a5008a9e2..376946335 100644 --- a/src/ui/wallets/account_summary.rs +++ b/src/ui/wallets/account_summary.rs @@ -182,6 +182,15 @@ impl AccountCategory { | AccountCategory::ProviderPlatform ) } + + /// Returns true if this is a "system" account category shown only in + /// developer mode under the consolidated System tab. + pub fn is_system_category(&self) -> bool { + !matches!( + self, + AccountCategory::Bip44 | AccountCategory::PlatformPayment + ) + } } pub(crate) fn categorize_account_path( diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index ee3c9a1a1..e9bf1fcfe 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -49,14 +49,17 @@ use dialogs::{ /// /// Each tab corresponds to either an `AccountCategory` or the special Shielded /// view. Visibility is controlled by developer mode: only DashCore, Platform, -/// and Shielded are shown by default; the rest appear in developer mode when -/// addresses of that type exist. +/// and Shielded are shown by default; the System tab appears in developer mode +/// and consolidates all system/dev account categories into collapsible sections. #[derive(Clone, PartialEq, Eq)] enum AccountTab { - /// Regular account category (BIP44, PlatformPayment, CoinJoin, etc.) + /// Regular account category (BIP44, PlatformPayment) Category(AccountCategory, Option), /// Shielded wallet view (replaces the old top-level Shielded tab) Shielded, + /// Consolidated system tab (developer mode only) — shows all non-primary + /// account categories as collapsible sections. + System, } impl Default for AccountTab { @@ -1066,7 +1069,7 @@ impl WalletsBalancesScreen { if self.app_context.is_developer_mode() { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let dev_tools_response = ui.button( - RichText::new("Dev Tools \u{25BC}") + RichText::new("Tools \u{25BC}") .color(DashColors::text_primary(dark_mode)) .strong(), ); @@ -1110,18 +1113,12 @@ impl WalletsBalancesScreen { } // Refresh Mode cycle button - ui.horizontal(|ui| { - ui.label( - RichText::new("Refresh Mode:") - .color(DashColors::text_primary(dark_mode)), - ); - if ui - .button(format!("{} \u{25B6}", self.refresh_mode.label())) - .clicked() - { - self.refresh_mode = self.refresh_mode.next(); - } - }); + if ui + .button(format!("Refresh mode: {}", self.refresh_mode.label())) + .clicked() + { + self.refresh_mode = self.refresh_mode.next(); + } }); }); }); @@ -1135,82 +1132,116 @@ impl WalletsBalancesScreen { fn build_account_tabs(&self, summaries: &[AccountSummary]) -> Vec { let developer_mode = self.app_context.is_developer_mode(); let mut tabs: Vec = Vec::new(); - let mut has_provider = false; + // Always-visible primary tabs: Dash Core (Bip44 index 0) and Platform + for summary in summaries { + let visible = summary.category.is_visible_in_default_mode() && summary.index == Some(0) + || summary.category == AccountCategory::PlatformPayment; + if !visible { + continue; + } + tabs.push(AccountTab::Category( + summary.category.clone(), + summary.index, + )); + } + + // Ensure Dash Core tab exists even without summaries + if !tabs + .iter() + .any(|t| matches!(t, AccountTab::Category(AccountCategory::Bip44, Some(0)))) + { + tabs.insert(0, AccountTab::Category(AccountCategory::Bip44, Some(0))); + } + + // Always add the Shielded tab + tabs.push(AccountTab::Shielded); + + // In developer mode, add the consolidated System tab last if developer_mode { - // In developer mode, show ALL account categories as tabs — even those - // without addresses — so developers can inspect every derivation path. - let all_categories = [ - AccountCategory::Bip44, - AccountCategory::PlatformPayment, - AccountCategory::Bip32, - AccountCategory::CoinJoin, - AccountCategory::IdentityRegistration, - AccountCategory::IdentitySystem, - AccountCategory::IdentityTopup, - AccountCategory::IdentityInvitation, - AccountCategory::ProviderOwner, - AccountCategory::ProviderVoting, - AccountCategory::ProviderOperator, - AccountCategory::ProviderPlatform, - ]; - - for cat in &all_categories { - let is_provider = matches!( - cat, - AccountCategory::ProviderVoting - | AccountCategory::ProviderOwner - | AccountCategory::ProviderOperator - | AccountCategory::ProviderPlatform - ); - if is_provider { - if has_provider { - continue; - } - has_provider = true; - } + tabs.push(AccountTab::System); + } - // Use the index from the summary if available, otherwise None - let idx = summaries - .iter() - .find(|s| &s.category == cat) - .and_then(|s| s.index); + tabs + } - // For Bip44, default to index 0 (Dash Core) - let idx = match cat { - AccountCategory::Bip44 => idx.or(Some(0)), - _ => idx, - }; + /// Collect the system account categories to display inside the System tab. + /// Returns `(category, index, address_count, balance_duffs)` tuples sorted + /// by the category's natural sort order. + fn system_tab_sections( + &self, + summaries: &[AccountSummary], + ) -> Vec<(AccountCategory, Option, usize, u64)> { + let all_system_categories = [ + AccountCategory::IdentityRegistration, + AccountCategory::IdentitySystem, + AccountCategory::IdentityTopup, + AccountCategory::IdentityInvitation, + AccountCategory::CoinJoin, + AccountCategory::ProviderOwner, + AccountCategory::ProviderVoting, + AccountCategory::ProviderOperator, + AccountCategory::ProviderPlatform, + AccountCategory::Bip32, + ]; - tabs.push(AccountTab::Category(cat.clone(), idx)); - } + let mut sections = Vec::new(); + for cat in &all_system_categories { + let matching: Vec<_> = summaries.iter().filter(|s| &s.category == cat).collect(); + let address_count = self.count_addresses_for_category(cat); + let balance: u64 = matching.iter().map(|s| s.confirmed_balance).sum(); + let idx = matching.first().and_then(|s| s.index); + sections.push((cat.clone(), idx, address_count, balance)); + } - // Also add any summary-only categories not in the fixed list (e.g. Other) - for summary in summaries { - let tab = AccountTab::Category(summary.category.clone(), summary.index); - if !tabs.contains(&tab) { - tabs.push(tab); - } - } - } else { - for summary in summaries { - let visible = summary.category.is_visible_in_default_mode() - && summary.index == Some(0) - || summary.category == AccountCategory::PlatformPayment; - if !visible { - continue; - } - tabs.push(AccountTab::Category( + // Also include any Other(...) categories from summaries + for summary in summaries { + if matches!(summary.category, AccountCategory::Other(_)) + && !sections.iter().any(|(c, _, _, _)| *c == summary.category) + { + let address_count = self.count_addresses_for_category(&summary.category); + sections.push(( summary.category.clone(), summary.index, + address_count, + summary.confirmed_balance, )); } } - // Always add the Shielded tab (after account categories) - tabs.push(AccountTab::Shielded); + sections + } - tabs + /// Count addresses belonging to a given category in the selected wallet. + fn count_addresses_for_category(&self, category: &AccountCategory) -> usize { + let Some(wallet_arc) = self.selected_wallet.as_ref() else { + return 0; + }; + let Ok(wallet) = wallet_arc.read() else { + return 0; + }; + let network = self.app_context.network; + wallet + .watched_addresses + .iter() + .filter(|(path, _info)| { + let (cat, _) = crate::ui::wallets::account_summary::categorize_account_path( + path, + network, + _info.path_reference, + ); + &cat == category + }) + .count() + } + + /// Format a duffs balance for tab labels: max 4 decimal places, trimmed. + fn format_tab_balance(duffs: u64) -> String { + let dash = duffs as f64 / 100_000_000.0; + // Format with 4 decimal places, then trim trailing zeros + let formatted = format!("{:.4}", dash); + let trimmed = formatted.trim_end_matches('0').trim_end_matches('.'); + format!("{} DASH", trimmed) } /// Render the Accounts & Addresses tab bar and content. @@ -1219,12 +1250,13 @@ impl WalletsBalancesScreen { let dark_mode = ui.ctx().style().visuals.dark_mode; ui.add_space(14.0); - ui.heading( - RichText::new("Accounts & Addresses").color(DashColors::text_primary(dark_mode)), - ); - ui.add_space(6.0); - if summaries.is_empty() && !matches!(self.selected_account_tab, AccountTab::Shielded) { + if summaries.is_empty() + && !matches!( + self.selected_account_tab, + AccountTab::Shielded | AccountTab::System + ) + { ui.label("No account activity yet."); return action; } @@ -1243,28 +1275,7 @@ impl WalletsBalancesScreen { for tab in &tabs { let (base_label, balance_duffs) = match tab { AccountTab::Category(cat, idx) => { - let balance = if matches!( - cat, - AccountCategory::ProviderVoting - | AccountCategory::ProviderOwner - | AccountCategory::ProviderOperator - | AccountCategory::ProviderPlatform - ) { - // Provider tab groups all provider categories - summaries - .iter() - .filter(|s| { - matches!( - s.category, - AccountCategory::ProviderVoting - | AccountCategory::ProviderOwner - | AccountCategory::ProviderOperator - | AccountCategory::ProviderPlatform - ) - }) - .map(|s| s.confirmed_balance) - .sum::() - } else if matches!(cat, AccountCategory::PlatformPayment) { + let balance = if matches!(cat, AccountCategory::PlatformPayment) { summaries .iter() .filter(|s| s.category == *cat && s.index == *idx) @@ -1288,18 +1299,34 @@ impl WalletsBalancesScreen { .unwrap_or(0); ("Shielded".to_string(), balance) } + AccountTab::System => { + let balance: u64 = summaries + .iter() + .filter(|s| s.category.is_system_category()) + .map(|s| s.confirmed_balance) + .sum(); + ("System".to_string(), balance) + } }; let label = if balance_duffs == 0 { format!("{} (empty)", base_label) } else { - format!("{} ({})", base_label, Self::format_dash(balance_duffs)) + format!( + "{} ({})", + base_label, + Self::format_tab_balance(balance_duffs) + ) }; let is_selected = &self.selected_account_tab == tab; let text = if is_selected { - RichText::new(&label).strong().color(DashColors::DASH_BLUE) + RichText::new(&label) + .strong() + .underline() + .color(DashColors::DASH_BLUE) } else { RichText::new(&label).color(DashColors::text_secondary(dark_mode)) }; + ui.add_space(4.0); if ui.selectable_label(is_selected, text).clicked() { self.selected_account_tab = tab.clone(); // Sync the selected_account for address_table filtering @@ -1328,6 +1355,9 @@ impl WalletsBalancesScreen { action |= shielded_view.ui(ui); } } + AccountTab::System => { + action |= self.render_system_tab_content(ui, summaries); + } AccountTab::Category(cat, idx) => { // Show description for the selected account category if let Some(description) = cat.description() { @@ -1340,22 +1370,7 @@ impl WalletsBalancesScreen { ui.add_space(4.0); } - // When in dev mode for provider tabs, filter to show all - // provider-type addresses - let is_provider_group = matches!( - cat, - AccountCategory::ProviderVoting - | AccountCategory::ProviderOwner - | AccountCategory::ProviderOperator - | AccountCategory::ProviderPlatform - ); - - if is_provider_group { - // Show all provider addresses, not just one sub-type - self.selected_account = Some((cat.clone(), *idx)); - } else { - self.selected_account = Some((cat.clone(), *idx)); - } + self.selected_account = Some((cat.clone(), *idx)); // Addresses (collapsible) let addresses_heading = format!("Addresses ({})", cat.label(*idx)); @@ -1414,6 +1429,56 @@ impl WalletsBalancesScreen { action } + /// Render the System tab content: each system account category as a + /// collapsible section, collapsed by default. + fn render_system_tab_content( + &mut self, + ui: &mut Ui, + summaries: &[AccountSummary], + ) -> AppAction { + let mut action = AppAction::None; + let dark_mode = ui.ctx().style().visuals.dark_mode; + let sections = self.system_tab_sections(summaries); + + for (cat, idx, addr_count, balance) in §ions { + let balance_text = if *balance == 0 { + "empty".to_string() + } else { + Self::format_tab_balance(*balance) + }; + let heading = format!( + "{} ({} addresses, {})", + cat.label(*idx), + addr_count, + balance_text + ); + let header = egui::CollapsingHeader::new( + RichText::new(heading) + .size(14.0) + .color(DashColors::text_primary(dark_mode)), + ) + .id_salt(format!("system_section_{:?}_{:?}", cat, idx)) + .default_open(false); + header.show(ui, |ui| { + if let Some(description) = cat.description() { + ui.label( + RichText::new(description) + .color(DashColors::text_secondary(dark_mode)) + .italics() + .size(12.0), + ); + ui.add_space(4.0); + } + + self.selected_account = Some((cat.clone(), *idx)); + action |= self.render_address_table(ui); + }); + ui.add_space(2.0); + } + + action + } + fn render_transactions_section(&self, ui: &mut Ui) { let Some(wallet_arc) = self.selected_wallet.as_ref() else { ui.label("Select a wallet to view its transaction history."); From 63ce0e1854e91c8b048334f047ce3bf4f82abb5d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:30:02 +0100 Subject: [PATCH 069/147] fix(shielded): scope commitment tree per wallet for multi-wallet correctness Each wallet's ClientPersistentCommitmentTree now uses its own dedicated SQLite file under /shielded/.db instead of sharing the main database's global commitment_tree_* tables. This prevents wallets from stepping on each other's Merkle tree state, which caused invalid witnesses and silently rejected transactions. Changes: - SHLD-001: Per-wallet commitment tree DB files via ClientPersistentCommitmentTree::open() - SHLD-002: clear_commitment_tree_for_wallet() scoped to a single wallet - SHLD-007: Safety-net resync guard now logs wallet seed hash before clearing - Migration v34: drops orphaned global commitment_tree_* tables - clear_network_data() deletes per-wallet tree files for the target network Co-Authored-By: Claude Opus 4.6 (1M context) --- src/context/shielded.rs | 38 ++++++++++++------ src/context/wallet_lifecycle.rs | 2 +- src/database/initialization.rs | 46 ++++++++++++++++++--- src/database/mod.rs | 46 +++++++++++++-------- src/database/shielded.rs | 71 ++++++++++++++++++++++++++++----- src/ui/wallets/shielded_tab.rs | 5 ++- 6 files changed, 159 insertions(+), 49 deletions(-) diff --git a/src/context/shielded.rs b/src/context/shielded.rs index fa3e643a7..ade2a0f12 100644 --- a/src/context/shielded.rs +++ b/src/context/shielded.rs @@ -4,6 +4,7 @@ use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::error::{TaskError, shielded_build_error}; use crate::backend_task::shielded::ShieldedTask; use crate::context::AppContext; +use crate::database::Database; use crate::model::wallet::WalletSeedHash; use crate::model::wallet::shielded::{ShieldedNote, ShieldedWalletState, derive_orchard_keys}; use dash_sdk::grovedb_commitment_tree::{ @@ -184,13 +185,17 @@ impl AppContext { let network_str = self.network.to_string(); - let commitment_tree = ClientPersistentCommitmentTree::open_on_shared_connection( - self.db.shared_connection(), - 100, - ) - .map_err(|e| TaskError::ShieldedTreeUpdateFailed { - detail: e.to_string(), - })?; + let tree_conn = + crate::database::shielded::open_commitment_tree_connection(&self.data_dir, &seed_hash) + .map_err(|e| TaskError::ShieldedTreeUpdateFailed { + detail: e.to_string(), + })?; + let commitment_tree = + ClientPersistentCommitmentTree::open(tree_conn, 100).map_err(|e| { + TaskError::ShieldedTreeUpdateFailed { + detail: e.to_string(), + } + })?; let mut last_synced_index = 0u64; @@ -246,18 +251,25 @@ impl AppContext { if state.last_synced_index > 0 && state.notes.is_empty() { let all_notes = self.db.get_all_shielded_notes(&seed_hash, &network_str)?; if all_notes.is_empty() { - tracing::info!( - "Shielded init: tree synced to index {} but no notes in DB — forcing full resync", + tracing::warn!( + "Shielded init: wallet {} tree synced to index {} but no notes in DB — forcing full resync", + hex::encode(seed_hash.as_slice()), state.last_synced_index, ); - self.db.clear_commitment_tree_tables()?; - let fresh_tree = ClientPersistentCommitmentTree::open_on_shared_connection( - self.db.shared_connection(), - 100, + Database::clear_commitment_tree_for_wallet(&self.data_dir, &seed_hash)?; + let fresh_conn = crate::database::shielded::open_commitment_tree_connection( + &self.data_dir, + &seed_hash, ) .map_err(|e| TaskError::ShieldedTreeUpdateFailed { detail: e.to_string(), })?; + let fresh_tree = + ClientPersistentCommitmentTree::open(fresh_conn, 100).map_err(|e| { + TaskError::ShieldedTreeUpdateFailed { + detail: e.to_string(), + } + })?; state.commitment_tree = std::sync::Mutex::new(fresh_tree); state.last_synced_index = 0; } diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index e91216933..1569af950 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -30,7 +30,7 @@ impl AppContext { } pub fn clear_network_database(&self) -> Result<(), TaskError> { - self.db.clear_network_data(self.network)?; + self.db.clear_network_data(self.network, &self.data_dir)?; if let Ok(mut wallets) = self.wallets.write() { wallets.clear(); diff --git a/src/database/initialization.rs b/src/database/initialization.rs index 719daff36..203404489 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -4,7 +4,7 @@ use rusqlite::{Connection, params}; use std::fs; use std::path::Path; -pub const DEFAULT_DB_VERSION: u16 = 33; +pub const DEFAULT_DB_VERSION: u16 = 34; pub const DEFAULT_NETWORK: &str = "mainnet"; @@ -55,6 +55,18 @@ impl Database { // numbering conflicts between the zk and v1.0-dev branches. // If migrating from < 28, these are no-ops that just bump the version. 28..=32 => {} + 34 => { + // Commitment trees are now stored in per-wallet SQLite files + // under /shielded/.db. Drop the old global + // tables that were shared across all wallets. + let _ = tx.execute( + "DROP TABLE IF EXISTS commitment_tree_checkpoint_marks_removed", + [], + ); + let _ = tx.execute("DROP TABLE IF EXISTS commitment_tree_checkpoints", []); + let _ = tx.execute("DROP TABLE IF EXISTS commitment_tree_cap", []); + let _ = tx.execute("DROP TABLE IF EXISTS commitment_tree_shards", []); + } 33 => { // Consolidated migration: all changes from v28-v32 in one step. // Every sub-migration is idempotent (IF NOT EXISTS / column checks), @@ -992,6 +1004,18 @@ mod test { assert!(exists, "table `{table}` should exist"); } + /// Helper: assert that a table does NOT exist in the database. + fn assert_table_not_exists(conn: &Connection, table: &str) { + let exists: bool = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1", + params![table], + |row| row.get::<_, i32>(0).map(|c| c > 0), + ) + .unwrap(); + assert!(!exists, "table `{table}` should NOT exist"); + } + /// Helper: assert that a column exists in a table. fn assert_column_exists(conn: &Connection, table: &str, column: &str) { let exists: bool = conn @@ -1043,6 +1067,14 @@ mod test { assert_table_exists(conn, "dashpay_contact_requests"); } + /// v34: global commitment tree tables must be gone (per-wallet DB files now). + fn assert_v34_schema(conn: &Connection) { + assert_table_not_exists(conn, "commitment_tree_shards"); + assert_table_not_exists(conn, "commitment_tree_cap"); + assert_table_not_exists(conn, "commitment_tree_checkpoints"); + assert_table_not_exists(conn, "commitment_tree_checkpoint_marks_removed"); + } + #[test] /// Given a new database file, /// when `initialize` is called, @@ -1144,9 +1176,10 @@ mod test { ) .unwrap(); assert_eq!(version, DEFAULT_DB_VERSION); - assert_eq!(version, 33); + assert_eq!(version, 34); assert_v33_schema(&conn); + assert_v34_schema(&conn); } #[test] @@ -1252,19 +1285,20 @@ mod test { // Verify version is 27 before migration assert_eq!(db.db_schema_version().unwrap(), 27); - // Run migration from v27 to v33 + // Run migration from v27 to current let result = db.try_perform_migration(27, DEFAULT_DB_VERSION); assert!( result.is_ok(), - "migration from v27 to v33 failed: {:?}", + "migration from v27 to v{DEFAULT_DB_VERSION} failed: {:?}", result.err() ); // Verify final version - assert_eq!(db.db_schema_version().unwrap(), 33); + assert_eq!(db.db_schema_version().unwrap(), 34); - // Verify full v33 schema + // Verify full v33+v34 schema let conn = db.conn.lock().unwrap(); assert_v33_schema(&conn); + assert_v34_schema(&conn); } } diff --git a/src/database/mod.rs b/src/database/mod.rs index beff03f6a..f362d4cf4 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -69,21 +69,19 @@ impl Database { }) } - /// Get a shared reference to the underlying connection. - /// - /// Used by `ClientPersistentCommitmentTree` to share the same SQLite - /// connection for the shielded commitment tree tables. - pub fn shared_connection(&self) -> Arc> { - self.conn.clone() - } - pub fn execute(&self, sql: &str, params: P) -> rusqlite::Result { let conn = self.conn.lock().unwrap(); conn.execute(sql, params) } /// Removes all application data tied to a specific Dash network. - pub fn clear_network_data(&self, network: Network) -> rusqlite::Result<()> { + /// + /// `data_dir` is needed to delete per-wallet commitment tree DB files. + pub fn clear_network_data( + &self, + network: Network, + data_dir: &std::path::Path, + ) -> rusqlite::Result<()> { let network_str = network.to_string(); let mut conn = self.conn.lock().unwrap(); let tx = conn.transaction()?; @@ -188,14 +186,28 @@ impl Database { rusqlite::params![&network_str], )?; - // Clear commitment tree tables (persistent shielded tree data). - // These tables are created by grovedb on first use, so they may not - // exist yet — ignore errors from missing tables. - let _ = tx.execute("DELETE FROM commitment_tree_shards", []); - let _ = tx.execute("DELETE FROM commitment_tree_cap", []); - let _ = tx.execute("DELETE FROM commitment_tree_checkpoints", []); - let _ = tx.execute("DELETE FROM commitment_tree_checkpoint_marks_removed", []); + // Collect wallet seed hashes for this network so we can delete their + // per-wallet commitment tree DB files after the transaction commits. + let mut wallet_hashes: Vec> = Vec::new(); + { + let mut stmt = tx.prepare("SELECT seed_hash FROM wallet WHERE network = ?1")?; + let rows = stmt.query_map(rusqlite::params![&network_str], |row| { + row.get::<_, Vec>(0) + })?; + for row in rows { + wallet_hashes.push(row?); + } + } + + tx.commit()?; + + // Delete per-wallet commitment tree DB files outside the transaction. + for hash_bytes in wallet_hashes { + if let Ok(seed_hash) = <[u8; 32]>::try_from(hash_bytes.as_slice()) { + let _ = shielded::delete_commitment_tree_db(data_dir, &seed_hash); + } + } - tx.commit() + Ok(()) } } diff --git a/src/database/shielded.rs b/src/database/shielded.rs index 41ba1eddb..447e3527b 100644 --- a/src/database/shielded.rs +++ b/src/database/shielded.rs @@ -1,6 +1,49 @@ use crate::database::Database; use crate::model::wallet::WalletSeedHash; use rusqlite::{Connection, params}; +use std::path::{Path, PathBuf}; + +/// Return the path to a wallet's dedicated commitment tree SQLite database. +/// +/// Each wallet gets its own file under `/shielded/` so that +/// commitment trees are fully isolated between wallets. +pub fn commitment_tree_db_path(data_dir: &Path, seed_hash: &WalletSeedHash) -> PathBuf { + let hex = hex::encode(seed_hash.as_slice()); + data_dir.join("shielded").join(format!("{hex}.db")) +} + +/// Open (or create) the per-wallet commitment tree SQLite database. +/// +/// Creates the `/shielded/` directory if it does not exist. +pub fn open_commitment_tree_connection( + data_dir: &Path, + seed_hash: &WalletSeedHash, +) -> Result { + let db_path = commitment_tree_db_path(data_dir, seed_hash); + if let Some(parent) = db_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + rusqlite::Error::InvalidParameterName(format!( + "Failed to create shielded DB directory: {e}" + )) + })?; + } + rusqlite::Connection::open(&db_path) +} + +/// Delete a wallet's dedicated commitment tree database file. +/// +/// Returns `Ok(true)` if a file was removed, `Ok(false)` if it did not exist. +pub fn delete_commitment_tree_db( + data_dir: &Path, + seed_hash: &WalletSeedHash, +) -> Result { + let db_path = commitment_tree_db_path(data_dir, seed_hash); + match std::fs::remove_file(&db_path) { + Ok(()) => Ok(true), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(e) => Err(e), + } +} impl Database { /// Create shielded pool tables (v28 migration). @@ -163,18 +206,24 @@ impl Database { ) } - /// Clear all commitment tree SQLite tables (used by resync). + /// Clear a wallet's commitment tree data by deleting its dedicated DB file. /// - /// The `ClientPersistentCommitmentTree` stores its shards, caps, and - /// checkpoints in `commitment_tree_*` tables. This deletes all rows so a - /// fresh tree can be opened on the same connection. - pub fn clear_commitment_tree_tables(&self) -> rusqlite::Result<()> { - let conn = self.conn.lock().unwrap(); - // Tables are created by grovedb on first use; ignore errors if missing. - let _ = conn.execute("DELETE FROM commitment_tree_shards", []); - let _ = conn.execute("DELETE FROM commitment_tree_cap", []); - let _ = conn.execute("DELETE FROM commitment_tree_checkpoints", []); - let _ = conn.execute("DELETE FROM commitment_tree_checkpoint_marks_removed", []); + /// Each wallet stores its `ClientPersistentCommitmentTree` in a separate + /// SQLite file under `/shielded/.db`. This removes that file + /// so a fresh tree can be opened on next initialization. + pub fn clear_commitment_tree_for_wallet( + data_dir: &Path, + seed_hash: &WalletSeedHash, + ) -> rusqlite::Result<()> { + tracing::warn!( + "Clearing commitment tree for wallet {}", + hex::encode(seed_hash.as_slice()), + ); + delete_commitment_tree_db(data_dir, seed_hash).map_err(|e| { + rusqlite::Error::InvalidParameterName(format!( + "Failed to delete commitment tree DB: {e}" + )) + })?; Ok(()) } diff --git a/src/ui/wallets/shielded_tab.rs b/src/ui/wallets/shielded_tab.rs index c7631c1b0..ce192e7c0 100644 --- a/src/ui/wallets/shielded_tab.rs +++ b/src/ui/wallets/shielded_tab.rs @@ -555,7 +555,10 @@ impl ShieldedTabView { .app_context .db .delete_shielded_notes(&self.seed_hash, &network_str); - let _ = self.app_context.db.clear_commitment_tree_tables(); + let _ = crate::database::Database::clear_commitment_tree_for_wallet( + &self.app_context.data_dir, + &self.seed_hash, + ); self.shielded_balance = 0; self.tree_synced = false; From e41a72d5a2d3dfecc88e88293f60459e67e47c62 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:05:51 +0100 Subject: [PATCH 070/147] fix(shielded): prevent permanent state leak on sync_notes failure in with_anchor_retry When sync_notes() failed after an anchor mismatch, the early `?` return skipped re-inserting the shielded state back into the HashMap, permanently orphaning it until app restart. Now the sync result is captured without early return, ensuring the state is always re-inserted regardless of success or failure. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/context/shielded.rs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/context/shielded.rs b/src/context/shielded.rs index ade2a0f12..bf3b73317 100644 --- a/src/context/shielded.rs +++ b/src/context/shielded.rs @@ -452,19 +452,23 @@ impl AppContext { tracing::info!( "Shielded anchor mismatch during {operation_name} — syncing notes and retrying" ); - crate::backend_task::shielded::sync::sync_notes( + let sync_result = crate::backend_task::shielded::sync::sync_notes( self, seed_hash, &mut state, self.network, ) - .await - .map_err(|e| { - tracing::warn!("Note sync after anchor mismatch failed: {e}"); - e - })?; - state.last_notes_synced_at = Some(std::time::Instant::now()); - operation(&state).await + .await; + match sync_result { + Ok(_) => { + state.last_notes_synced_at = Some(std::time::Instant::now()); + operation(&state).await + } + Err(e) => { + tracing::warn!("Note sync after anchor mismatch failed: {e}"); + Err(e) + } + } } else { result }; From 6e775a4dad9727f402cf9921d0ef38f39ef649f1 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:05:59 +0100 Subject: [PATCH 071/147] fix(ui): apply password change for current session even when config save fails The in-memory config update and SDK reinit only ran on the save-success path, silently discarding the password change on save failure despite the banner promising session-level application. Now the in-memory update and reinit run unconditionally; only the banner text varies across the four (save x reinit) outcome combinations. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/network_chooser_screen.rs | 62 ++++++++++++++++++++------------ 1 file changed, 40 insertions(+), 22 deletions(-) diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index 6dd7b52c1..7e70f8822 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -491,19 +491,20 @@ impl NetworkChooserScreen { self.current_network, updated_config.clone(), ); - if let Err(e) = config.save(&self.mainnet_app_context.data_dir) { + let save_failed = if let Err(e) = + config.save(&self.mainnet_app_context.data_dir) + { tracing::error!("Failed to save config to .env: {e}"); - MessageBanner::set_global( - ui.ctx(), - "Could not save the configuration file. Your changes will apply for this session only.", - MessageType::Warning, - ); + true } else { + false + }; - // Only update the in-memory config and reinit if the - // context for this network already exists. If it - // doesn't, `context_for_network` would silently fall - // back to mainnet and corrupt its config. The saved + // Update in-memory config and reinit regardless of save + // result, so the password takes effect for this session. + // Only do so when the context for this network already + // exists — otherwise `context_for_network` would silently + // fall back to mainnet and corrupt its config. The saved // file-level config will be picked up when the network // context is created. let network_context_exists = match self.current_network { @@ -514,14 +515,13 @@ impl NetworkChooserScreen { _ => false, }; - if network_context_exists { + let reinit_failed = if network_context_exists { let app_context = self.context_for_network(self.current_network); { let mut cfg_lock = app_context.config.write().unwrap(); *cfg_lock = updated_config; } - // Clear stale auth/connection error banners before showing result MessageBanner::clear_all_global(ui.ctx()); if let Err(e) = Arc::clone(app_context).reinit_core_client_and_sdk() @@ -531,26 +531,44 @@ impl NetworkChooserScreen { 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, ); - } else { + } + (true, false) => { MessageBanner::set_global( ui.ctx(), - "Core RPC password saved successfully.", - MessageType::Success, + "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, ); } - } else { - MessageBanner::set_global( - ui.ctx(), - "Core RPC password saved successfully.", - MessageType::Success, - ); } - } // else: config.save() succeeded } }); } From 95782ae344f18bdd03e009b73d5f2af1e48c479e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:06:05 +0100 Subject: [PATCH 072/147] fix(error): use dedicated TaskError variants for non-build wallet errors reload_utxos() and recalculate_affected_address_balances() errors were routed through shielded_build_error() which pattern-matches for build- specific patterns like "AnchorMismatch". These are wallet operations, not shielded builds. Added WalletUtxoReloadFailed and WalletBalanceRecalculationFailed variants with appropriate user-facing messages. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend_task/error.rs | 19 +++++++++++++++---- src/backend_task/shielded/bundle.rs | 4 ++-- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index 67ee9b4f1..440785d8e 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -63,12 +63,14 @@ pub enum TaskError { /// Could not connect to Dash Core at the configured address. #[error( - "Could not connect to Dash Core at {url}. Check that Dash Core is running and your network settings are correct." + "Could not connect to Dash Core at {url}.{} Check that Dash Core is running and your network settings are correct.", + detail.as_ref().map(|d| format!(" {d}")).unwrap_or_default() )] CoreRpcConnectionFailed { url: String, + detail: Option, #[source] - source: Option, + source: Option>, }, /// A Dash Core RPC call failed. @@ -93,6 +95,14 @@ pub enum TaskError { #[error("Wallet is locked. Please unlock your wallet and try again.")] WalletLocked, + /// Refreshing wallet UTXOs from Dash Core failed. + #[error("Could not refresh wallet balance. Please try again.")] + WalletUtxoReloadFailed { detail: String }, + + /// Recalculating address balances after a transaction failed. + #[error("Could not update wallet balances after transaction. Please refresh your wallet.")] + WalletBalanceRecalculationFailed { detail: String }, + /// The requested document could not be found on the platform. #[error("The document could not be found. It may have been deleted or the ID is incorrect.")] DocumentNotFound, @@ -1443,9 +1453,10 @@ mod tests { ); let err = TaskError::CoreRpcConnectionFailed { url: "127.0.0.1:9998".to_string(), - source: Some(dashcore_rpc::Error::JsonRpc( + detail: None, + source: Some(Box::new(dashcore_rpc::Error::JsonRpc( dashcore_rpc::jsonrpc::error::Error::Transport(Box::new(socket_err)), - )), + ))), }; let msg = err.to_string(); assert!( diff --git a/src/backend_task/shielded/bundle.rs b/src/backend_task/shielded/bundle.rs index 5649481ea..54bde394a 100644 --- a/src/backend_task/shielded/bundle.rs +++ b/src/backend_task/shielded/bundle.rs @@ -468,7 +468,7 @@ pub async fn shield_from_asset_lock( Err(_) => { wallet .reload_utxos(app_context.as_ref()) - .map_err(shielded_build_error)?; + .map_err(|detail| TaskError::WalletUtxoReloadFailed { detail })?; let (tx, private_key, address, _change, utxos) = wallet .generic_asset_lock_transaction( @@ -527,7 +527,7 @@ pub async fn shield_from_asset_lock( wallet .recalculate_affected_address_balances(&used_utxos, app_context.as_ref()) - .map_err(shielded_build_error)?; + .map_err(|detail| TaskError::WalletBalanceRecalculationFailed { detail })?; } // Step 5: Wait for asset lock proof (InstantLock or ChainLock) with timeout From a12cc6ace6a1c23ec14101098b82b2f14f63e76d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:06:15 +0100 Subject: [PATCH 073/147] fix(error): preserve error chain in CoreRpcConnectionFailed chain_lock_rpc_error() took the RPC error by reference and created CoreRpcConnectionFailed with source: None, dropping all diagnostic info. Added a detail: Option field to the variant to carry the formatted error. Boxed the source field to keep the enum size under the clippy threshold. Updated all constructors. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend_task/core/mod.rs | 6 +++++- src/context/mod.rs | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/backend_task/core/mod.rs b/src/backend_task/core/mod.rs index 867723d5e..51b2eb943 100644 --- a/src/backend_task/core/mod.rs +++ b/src/backend_task/core/mod.rs @@ -489,7 +489,11 @@ impl AppContext { .as_ref() .map(|c| format!("{}:{}", c.core_host, c.core_rpc_port)) .unwrap_or_else(|| "unknown".to_string()); - return Some(TaskError::CoreRpcConnectionFailed { url, source: None }); + return Some(TaskError::CoreRpcConnectionFailed { + url, + detail: Some(format!("{e}")), + source: None, + }); } None } diff --git a/src/context/mod.rs b/src/context/mod.rs index 2763ace82..f346aa430 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -649,7 +649,8 @@ impl AppContext { .unwrap_or_else(|| "unknown".to_string()); TaskError::CoreRpcConnectionFailed { url, - source: Some(e), + detail: None, + source: Some(Box::new(e)), } } else { TaskError::from(e) From 280820f814fade9876b92e7aaf83a8b052136690 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:11:32 +0100 Subject: [PATCH 074/147] feat(ui): two-column wallet header layout, rename Tools to Advanced Split the wallet detail panel header into left (name, total balance, action buttons) and right (collapsible balance breakdown, sync status) columns. Renamed the "Tools" dropdown to "Advanced" and moved it into the action buttons row instead of right-aligning it separately. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/wallets_screen/mod.rs | 184 +++++++++++++++------------ 1 file changed, 104 insertions(+), 80 deletions(-) diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index e9bf1fcfe..eda1c1eed 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -1065,63 +1065,59 @@ impl WalletsBalancesScreen { ui.add(egui::Spinner::new().color(DashColors::DASH_BLUE)); } - // Dev Tools dropdown button (developer mode only), right-aligned + // Advanced dropdown button (developer mode only) if self.app_context.is_developer_mode() { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let dev_tools_response = ui.button( - RichText::new("Tools \u{25BC}") - .color(DashColors::text_primary(dark_mode)) - .strong(), - ); - - let popup_id = ui.make_persistent_id("dev_tools_popup"); - egui::Popup::from_toggle_button_response(&dev_tools_response) - .id(popup_id) - .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) - .frame( - egui::Frame::popup(ui.style()).fill(DashColors::popup_fill(dark_mode)), - ) - .show(|ui| { - ui.set_min_width(160.0); - ui.with_layout(egui::Layout::top_down(egui::Align::RIGHT), |ui| { - // Get Test Dash (opens browser to faucet) - if matches!( - self.app_context.network, - dash_sdk::dpp::dashcore::Network::Testnet - ) && ui.button("Get Test Dash").clicked() - { - ui.ctx().open_url(egui::OpenUrl::new_tab( - "https://faucet.testnet.networks.dash.org/", - )); - } + let advanced_response = ui.button( + RichText::new("Advanced \u{25BC}") + .color(DashColors::text_primary(dark_mode)) + .strong(), + ); - // Mine button (Regtest/Devnet with RPC only) - if matches!( - self.app_context.network, - dash_sdk::dpp::dashcore::Network::Regtest - | dash_sdk::dpp::dashcore::Network::Devnet - ) && self.app_context.core_backend_mode() == CoreBackendMode::Rpc - && ui - .button( - RichText::new("Mine") - .color(DashColors::text_primary(dark_mode)) - .strong(), - ) - .clicked() - { - self.open_mine_dialog(); - } + let popup_id = ui.make_persistent_id("advanced_popup"); + egui::Popup::from_toggle_button_response(&advanced_response) + .id(popup_id) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .frame(egui::Frame::popup(ui.style()).fill(DashColors::popup_fill(dark_mode))) + .show(|ui| { + ui.set_min_width(160.0); + ui.with_layout(egui::Layout::top_down(egui::Align::RIGHT), |ui| { + // Get Test Dash (opens browser to faucet) + if matches!( + self.app_context.network, + dash_sdk::dpp::dashcore::Network::Testnet + ) && ui.button("Get Test Dash").clicked() + { + ui.ctx().open_url(egui::OpenUrl::new_tab( + "https://faucet.testnet.networks.dash.org/", + )); + } - // Refresh Mode cycle button - if ui - .button(format!("Refresh mode: {}", self.refresh_mode.label())) + // Mine button (Regtest/Devnet with RPC only) + if matches!( + self.app_context.network, + dash_sdk::dpp::dashcore::Network::Regtest + | dash_sdk::dpp::dashcore::Network::Devnet + ) && self.app_context.core_backend_mode() == CoreBackendMode::Rpc + && ui + .button( + RichText::new("Mine") + .color(DashColors::text_primary(dark_mode)) + .strong(), + ) .clicked() - { - self.refresh_mode = self.refresh_mode.next(); - } - }); + { + self.open_mine_dialog(); + } + + // Refresh Mode cycle button + if ui + .button(format!("Refresh mode: {}", self.refresh_mode.label())) + .clicked() + { + self.refresh_mode = self.refresh_mode.next(); + } }); - }); + }); } }); @@ -1935,23 +1931,29 @@ impl WalletsBalancesScreen { ); } - /// Render the collapsible balance breakdown section. - fn render_balance_breakdown(&mut self, ui: &mut Ui, wallet: &Wallet) { + /// Render the total balance label only (used in the left column of the header). + fn render_balance_total(&self, ui: &mut Ui, wallet: &Wallet) { let dark_mode = ui.ctx().style().visuals.dark_mode; let core_balance = wallet.total_balance_duffs(); let platform_balance = Self::platform_balance_duffs(wallet); let shielded_balance = self.shielded_balance_duffs(&wallet.seed_hash()); let total = core_balance + platform_balance + shielded_balance; - // Total balance (always visible) ui.label( RichText::new(format!("Balance: {}", Self::format_dash(total))) .color(DashColors::text_primary(dark_mode)) .size(20.0) .strong(), ); + } + + /// Render the collapsible breakdown detail (used in the right column of the header). + fn render_balance_breakdown_detail(&mut self, ui: &mut Ui, wallet: &Wallet) { + let dark_mode = ui.ctx().style().visuals.dark_mode; + let core_balance = wallet.total_balance_duffs(); + let platform_balance = Self::platform_balance_duffs(wallet); + let shielded_balance = self.shielded_balance_duffs(&wallet.seed_hash()); - // Collapsible breakdown let header = egui::CollapsingHeader::new( RichText::new("Balance breakdown") .size(13.0) @@ -1999,36 +2001,58 @@ impl WalletsBalancesScreen { .fill(DashColors::surface(dark_mode)) .inner_margin(Margin::symmetric(18, 16)) .show(col, |ui| { - // --- 1. Wallet Header --- + // --- Two-column header --- + let available = ui.available_width(); + let left_width = available * 0.55; + let right_width = available - left_width; + ui.horizontal(|ui| { - ui.heading( - RichText::new(alias.clone()) - .color(DashColors::text_primary(dark_mode)) - .size(25.0), - ); + // LEFT COLUMN: name, total balance, action buttons + ui.vertical(|ui| { + ui.set_width(left_width); + + // Wallet name + [DEV] badge + ui.horizontal(|ui| { + ui.heading( + RichText::new(alias.clone()) + .color(DashColors::text_primary(dark_mode)) + .size(25.0), + ); + if self.app_context.is_developer_mode() { + ui.label( + RichText::new("[DEV]") + .color(DashColors::text_secondary(dark_mode)) + .size(12.0), + ); + } + }); - if self.app_context.is_developer_mode() { - ui.label( - RichText::new("[DEV]") - .color(DashColors::text_secondary(dark_mode)) - .size(12.0), - ); - } - }); + // Total balance line + { + let wallet = wallet_arc.read().unwrap(); + self.render_balance_total(ui, &wallet); + } - // --- 2. Balance with collapsible breakdown --- - { - let wallet = wallet_arc.read().unwrap(); - self.render_balance_breakdown(ui, &wallet); - } + // Action buttons (Send, Receive, spinner, Advanced) + action |= self.render_action_buttons(ui, ctx); + }); + + // RIGHT COLUMN: balance breakdown + sync status, right-aligned + ui.with_layout(egui::Layout::top_down(egui::Align::Max), |ui| { + ui.set_width(right_width); - // --- 3. Sync Status (collapsible) --- - self.render_sync_status(ui); + // Collapsible balance breakdown + { + let wallet = wallet_arc.read().unwrap(); + self.render_balance_breakdown_detail(ui, &wallet); + } - // --- 4. Action Buttons (Send, Receive, Dev Tools) --- - action |= self.render_action_buttons(ui, ctx); + // Collapsible sync status + self.render_sync_status(ui); + }); + }); - // --- 5. Accounts & Addresses (tabs) --- + // --- Accounts & Addresses (tabs, full-width below header) --- ui.add_space(10.0); ui.separator(); From 344c22942e0d76fdc5ee7b8e9a1745d00d8a0837 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:41:15 +0100 Subject: [PATCH 075/147] fix(test): rename test_v33_migration_fresh_install to match DB version 34 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/database/initialization.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/database/initialization.rs b/src/database/initialization.rs index f97e6a1f8..09968d358 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -1160,7 +1160,7 @@ mod test { } #[test] - fn test_v33_migration_fresh_install() { + fn test_v34_migration_fresh_install() { let temp_dir = tempfile::tempdir().unwrap(); let db_file_path = temp_dir.path().join("fresh.db"); let db = super::Database::new(&db_file_path).unwrap(); From 712049bc3d904faff350b88551b6a1fa31eeab05 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:12:39 +0100 Subject: [PATCH 076/147] revert(shielded): use shared main DB for commitment tree instead of per-wallet files Reverts the per-wallet SQLite file approach (63ce0e18). Multiple wallets share the same commitment_tree_* tables in the main database. This is accepted behavior until the SDK adds proper wallet-scoping support (dashpay/grovedb#653). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/context/shielded.rs | 37 ++++++++---------- src/context/wallet_lifecycle.rs | 2 +- src/database/initialization.rs | 45 +++------------------- src/database/mod.rs | 32 +++------------- src/database/shielded.rs | 68 ++++----------------------------- src/ui/wallets/shielded_tab.rs | 5 +-- 6 files changed, 35 insertions(+), 154 deletions(-) diff --git a/src/context/shielded.rs b/src/context/shielded.rs index bf3b73317..34a10badc 100644 --- a/src/context/shielded.rs +++ b/src/context/shielded.rs @@ -4,7 +4,6 @@ use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::error::{TaskError, shielded_build_error}; use crate::backend_task::shielded::ShieldedTask; use crate::context::AppContext; -use crate::database::Database; use crate::model::wallet::WalletSeedHash; use crate::model::wallet::shielded::{ShieldedNote, ShieldedWalletState, derive_orchard_keys}; use dash_sdk::grovedb_commitment_tree::{ @@ -185,17 +184,13 @@ impl AppContext { let network_str = self.network.to_string(); - let tree_conn = - crate::database::shielded::open_commitment_tree_connection(&self.data_dir, &seed_hash) - .map_err(|e| TaskError::ShieldedTreeUpdateFailed { - detail: e.to_string(), - })?; - let commitment_tree = - ClientPersistentCommitmentTree::open(tree_conn, 100).map_err(|e| { - TaskError::ShieldedTreeUpdateFailed { - detail: e.to_string(), - } - })?; + let commitment_tree = ClientPersistentCommitmentTree::open_on_shared_connection( + self.db.shared_connection(), + 100, + ) + .map_err(|e| TaskError::ShieldedTreeUpdateFailed { + detail: e.to_string(), + })?; let mut last_synced_index = 0u64; @@ -256,20 +251,18 @@ impl AppContext { hex::encode(seed_hash.as_slice()), state.last_synced_index, ); - Database::clear_commitment_tree_for_wallet(&self.data_dir, &seed_hash)?; - let fresh_conn = crate::database::shielded::open_commitment_tree_connection( - &self.data_dir, - &seed_hash, + self.db.clear_commitment_tree_tables().map_err(|e| { + TaskError::ShieldedTreeUpdateFailed { + detail: e.to_string(), + } + })?; + let fresh_tree = ClientPersistentCommitmentTree::open_on_shared_connection( + self.db.shared_connection(), + 100, ) .map_err(|e| TaskError::ShieldedTreeUpdateFailed { detail: e.to_string(), })?; - let fresh_tree = - ClientPersistentCommitmentTree::open(fresh_conn, 100).map_err(|e| { - TaskError::ShieldedTreeUpdateFailed { - detail: e.to_string(), - } - })?; state.commitment_tree = std::sync::Mutex::new(fresh_tree); state.last_synced_index = 0; } diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index 1569af950..e91216933 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -30,7 +30,7 @@ impl AppContext { } pub fn clear_network_database(&self) -> Result<(), TaskError> { - self.db.clear_network_data(self.network, &self.data_dir)?; + self.db.clear_network_data(self.network)?; if let Ok(mut wallets) = self.wallets.write() { wallets.clear(); diff --git a/src/database/initialization.rs b/src/database/initialization.rs index 09968d358..42805033d 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -4,7 +4,7 @@ use rusqlite::{Connection, params}; use std::fs; use std::path::Path; -pub const DEFAULT_DB_VERSION: u16 = 34; +pub const DEFAULT_DB_VERSION: u16 = 33; pub const DEFAULT_NETWORK: &str = "mainnet"; @@ -55,18 +55,6 @@ impl Database { // numbering conflicts between the zk and v1.0-dev branches. // If migrating from < 28, these are no-ops that just bump the version. 28..=32 => {} - 34 => { - // Commitment trees are now stored in per-wallet SQLite files - // under /shielded/.db. Drop the old global - // tables that were shared across all wallets. - let _ = tx.execute( - "DROP TABLE IF EXISTS commitment_tree_checkpoint_marks_removed", - [], - ); - let _ = tx.execute("DROP TABLE IF EXISTS commitment_tree_checkpoints", []); - let _ = tx.execute("DROP TABLE IF EXISTS commitment_tree_cap", []); - let _ = tx.execute("DROP TABLE IF EXISTS commitment_tree_shards", []); - } 33 => { // Consolidated migration: all changes from v28-v32 in one step. // Every sub-migration is idempotent (IF NOT EXISTS / column checks), @@ -1005,18 +993,6 @@ mod test { assert!(exists, "table `{table}` should exist"); } - /// Helper: assert that a table does NOT exist in the database. - fn assert_table_not_exists(conn: &Connection, table: &str) { - let exists: bool = conn - .query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1", - params![table], - |row| row.get::<_, i32>(0).map(|c| c > 0), - ) - .unwrap(); - assert!(!exists, "table `{table}` should NOT exist"); - } - /// Helper: assert that a column exists in a table. fn assert_column_exists(conn: &Connection, table: &str, column: &str) { let exists: bool = conn @@ -1068,14 +1044,6 @@ mod test { assert_table_exists(conn, "dashpay_contact_requests"); } - /// v34: global commitment tree tables must be gone (per-wallet DB files now). - fn assert_v34_schema(conn: &Connection) { - assert_table_not_exists(conn, "commitment_tree_shards"); - assert_table_not_exists(conn, "commitment_tree_cap"); - assert_table_not_exists(conn, "commitment_tree_checkpoints"); - assert_table_not_exists(conn, "commitment_tree_checkpoint_marks_removed"); - } - #[test] /// Given a new database file, /// when `initialize` is called, @@ -1160,7 +1128,7 @@ mod test { } #[test] - fn test_v34_migration_fresh_install() { + fn test_v33_migration_fresh_install() { let temp_dir = tempfile::tempdir().unwrap(); let db_file_path = temp_dir.path().join("fresh.db"); let db = super::Database::new(&db_file_path).unwrap(); @@ -1168,7 +1136,6 @@ mod test { let conn = db.conn.lock().unwrap(); - // Version must be 33 let version: u16 = conn .query_row( "SELECT database_version FROM settings WHERE id = 1", @@ -1177,10 +1144,9 @@ mod test { ) .unwrap(); assert_eq!(version, DEFAULT_DB_VERSION); - assert_eq!(version, 34); + assert_eq!(version, 33); assert_v33_schema(&conn); - assert_v34_schema(&conn); } #[test] @@ -1295,11 +1261,10 @@ mod test { ); // Verify final version - assert_eq!(db.db_schema_version().unwrap(), 34); + assert_eq!(db.db_schema_version().unwrap(), 33); - // Verify full v33+v34 schema + // Verify full v33 schema let conn = db.conn.lock().unwrap(); assert_v33_schema(&conn); - assert_v34_schema(&conn); } } diff --git a/src/database/mod.rs b/src/database/mod.rs index f362d4cf4..ba08688d5 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -69,19 +69,17 @@ impl Database { }) } + pub(crate) fn shared_connection(&self) -> Arc> { + self.conn.clone() + } + pub fn execute(&self, sql: &str, params: P) -> rusqlite::Result { let conn = self.conn.lock().unwrap(); conn.execute(sql, params) } /// Removes all application data tied to a specific Dash network. - /// - /// `data_dir` is needed to delete per-wallet commitment tree DB files. - pub fn clear_network_data( - &self, - network: Network, - data_dir: &std::path::Path, - ) -> rusqlite::Result<()> { + pub fn clear_network_data(&self, network: Network) -> rusqlite::Result<()> { let network_str = network.to_string(); let mut conn = self.conn.lock().unwrap(); let tx = conn.transaction()?; @@ -186,27 +184,9 @@ impl Database { rusqlite::params![&network_str], )?; - // Collect wallet seed hashes for this network so we can delete their - // per-wallet commitment tree DB files after the transaction commits. - let mut wallet_hashes: Vec> = Vec::new(); - { - let mut stmt = tx.prepare("SELECT seed_hash FROM wallet WHERE network = ?1")?; - let rows = stmt.query_map(rusqlite::params![&network_str], |row| { - row.get::<_, Vec>(0) - })?; - for row in rows { - wallet_hashes.push(row?); - } - } - tx.commit()?; - // Delete per-wallet commitment tree DB files outside the transaction. - for hash_bytes in wallet_hashes { - if let Ok(seed_hash) = <[u8; 32]>::try_from(hash_bytes.as_slice()) { - let _ = shielded::delete_commitment_tree_db(data_dir, &seed_hash); - } - } + self.clear_commitment_tree_tables()?; Ok(()) } diff --git a/src/database/shielded.rs b/src/database/shielded.rs index 447e3527b..d1154ca95 100644 --- a/src/database/shielded.rs +++ b/src/database/shielded.rs @@ -1,49 +1,6 @@ use crate::database::Database; use crate::model::wallet::WalletSeedHash; use rusqlite::{Connection, params}; -use std::path::{Path, PathBuf}; - -/// Return the path to a wallet's dedicated commitment tree SQLite database. -/// -/// Each wallet gets its own file under `/shielded/` so that -/// commitment trees are fully isolated between wallets. -pub fn commitment_tree_db_path(data_dir: &Path, seed_hash: &WalletSeedHash) -> PathBuf { - let hex = hex::encode(seed_hash.as_slice()); - data_dir.join("shielded").join(format!("{hex}.db")) -} - -/// Open (or create) the per-wallet commitment tree SQLite database. -/// -/// Creates the `/shielded/` directory if it does not exist. -pub fn open_commitment_tree_connection( - data_dir: &Path, - seed_hash: &WalletSeedHash, -) -> Result { - let db_path = commitment_tree_db_path(data_dir, seed_hash); - if let Some(parent) = db_path.parent() { - std::fs::create_dir_all(parent).map_err(|e| { - rusqlite::Error::InvalidParameterName(format!( - "Failed to create shielded DB directory: {e}" - )) - })?; - } - rusqlite::Connection::open(&db_path) -} - -/// Delete a wallet's dedicated commitment tree database file. -/// -/// Returns `Ok(true)` if a file was removed, `Ok(false)` if it did not exist. -pub fn delete_commitment_tree_db( - data_dir: &Path, - seed_hash: &WalletSeedHash, -) -> Result { - let db_path = commitment_tree_db_path(data_dir, seed_hash); - match std::fs::remove_file(&db_path) { - Ok(()) => Ok(true), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(e) => Err(e), - } -} impl Database { /// Create shielded pool tables (v28 migration). @@ -206,24 +163,13 @@ impl Database { ) } - /// Clear a wallet's commitment tree data by deleting its dedicated DB file. - /// - /// Each wallet stores its `ClientPersistentCommitmentTree` in a separate - /// SQLite file under `/shielded/.db`. This removes that file - /// so a fresh tree can be opened on next initialization. - pub fn clear_commitment_tree_for_wallet( - data_dir: &Path, - seed_hash: &WalletSeedHash, - ) -> rusqlite::Result<()> { - tracing::warn!( - "Clearing commitment tree for wallet {}", - hex::encode(seed_hash.as_slice()), - ); - delete_commitment_tree_db(data_dir, seed_hash).map_err(|e| { - rusqlite::Error::InvalidParameterName(format!( - "Failed to delete commitment tree DB: {e}" - )) - })?; + /// Clear all commitment tree data from the shared database. + pub fn clear_commitment_tree_tables(&self) -> rusqlite::Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM commitment_tree_shards", [])?; + conn.execute("DELETE FROM commitment_tree_cap", [])?; + conn.execute("DELETE FROM commitment_tree_checkpoints", [])?; + conn.execute("DELETE FROM commitment_tree_checkpoint_marks_removed", [])?; Ok(()) } diff --git a/src/ui/wallets/shielded_tab.rs b/src/ui/wallets/shielded_tab.rs index ce192e7c0..c7631c1b0 100644 --- a/src/ui/wallets/shielded_tab.rs +++ b/src/ui/wallets/shielded_tab.rs @@ -555,10 +555,7 @@ impl ShieldedTabView { .app_context .db .delete_shielded_notes(&self.seed_hash, &network_str); - let _ = crate::database::Database::clear_commitment_tree_for_wallet( - &self.app_context.data_dir, - &self.seed_hash, - ); + let _ = self.app_context.db.clear_commitment_tree_tables(); self.shielded_balance = 0; self.tree_synced = false; From daa825b3fd215a27e3b4469c306fbb9641932ccb Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:25:32 +0100 Subject: [PATCH 077/147] fix(shielded): fix stale clear_commitment_tree_for_wallet reference after merge Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/shielded_tab.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/ui/wallets/shielded_tab.rs b/src/ui/wallets/shielded_tab.rs index ee300ab4a..495b2cee2 100644 --- a/src/ui/wallets/shielded_tab.rs +++ b/src/ui/wallets/shielded_tab.rs @@ -561,10 +561,7 @@ impl ShieldedTabView { .app_context .db .delete_shielded_notes(&self.seed_hash, &network_str); - let _ = crate::database::Database::clear_commitment_tree_for_wallet( - &self.app_context.data_dir, - &self.seed_hash, - ); + let _ = self.app_context.db.clear_commitment_tree_tables(); self.shielded_balance = 0; self.tree_synced = false; From b561c3e5a530a8f4f59b0f85bc67a0a0170313f7 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:15:05 +0100 Subject: [PATCH 078/147] fix(shielded): harden anchor retry, commitment tree clearing, and state guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SEC-002: Verify shielded balance after anchor retry sync before retrying the operation — prevents doomed retries with zero balance. SEC-003: Handle missing commitment tree tables in clear_commitment_tree_tables (grovedb creates them lazily, so DELETEs fail on fresh installs). Fix clear_network_data to log-and-continue when commitment tree clearing fails — these tables are optional and shouldn't block network data reset. Add SEC-001 INTENTIONAL annotation for panic safety in with_anchor_retry. Document shield_from_asset_lock_task: anchor retry is not applicable since it only reads the payment address and doesn't use the commitment tree. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/context/shielded.rs | 23 +++++++++++++++++++++-- src/database/mod.rs | 7 ++++++- src/database/shielded.rs | 22 ++++++++++++++++++---- 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/src/context/shielded.rs b/src/context/shielded.rs index 34a10badc..57ffd42db 100644 --- a/src/context/shielded.rs +++ b/src/context/shielded.rs @@ -441,6 +441,9 @@ impl AppContext { let result = operation(&state).await; + // INTENTIONAL(SEC-001): Shielded state removed from map during async operation. + // Panic during shielded operation loses state for the session — restart recovers. + let result = if matches!(result, Err(TaskError::ShieldedAnchorMismatch { .. })) { tracing::info!( "Shielded anchor mismatch during {operation_name} — syncing notes and retrying" @@ -455,7 +458,19 @@ impl AppContext { match sync_result { Ok(_) => { state.last_notes_synced_at = Some(std::time::Instant::now()); - operation(&state).await + // Fix SEC-002: verify sufficient balance after sync before retrying + state.recalculate_balance(); + if state.shielded_balance == 0 { + tracing::warn!( + "Shielded {operation_name}: no unspent balance after anchor retry sync" + ); + Err(TaskError::ShieldedInsufficientBalance { + available: 0, + required: 1, + }) + } else { + operation(&state).await + } } Err(e) => { tracing::warn!("Note sync after anchor mismatch failed: {e}"); @@ -488,6 +503,10 @@ impl AppContext { } /// Shield core DASH directly into the shielded pool via asset lock. + /// + /// Unlike operations that spend shielded notes (transfer, unshield, withdrawal), + /// shield-from-asset-lock only reads the payment address — it doesn't use the + /// commitment tree for witnesses, so anchor retry is not applicable. async fn shield_from_asset_lock_task( self: &Arc, seed_hash: WalletSeedHash, @@ -506,7 +525,7 @@ impl AppContext { ) .await; - // Put state back + // Always put state back { let mut states = self.shielded_states.lock().unwrap(); states.insert(seed_hash, state_ref); diff --git a/src/database/mod.rs b/src/database/mod.rs index ba08688d5..4fc1dcdbf 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -186,7 +186,12 @@ impl Database { tx.commit()?; - self.clear_commitment_tree_tables()?; + // Commitment tree tables are optional (created lazily by grovedb). + // Log and continue if clearing them fails — the main network data + // has already been committed above. + if let Err(e) = self.clear_commitment_tree_tables() { + tracing::warn!("Failed to clear commitment tree tables: {e}"); + } Ok(()) } diff --git a/src/database/shielded.rs b/src/database/shielded.rs index d1154ca95..e36fcce3f 100644 --- a/src/database/shielded.rs +++ b/src/database/shielded.rs @@ -164,12 +164,26 @@ impl Database { } /// Clear all commitment tree data from the shared database. + /// + /// Handles fresh installs where grovedb creates these tables lazily — + /// each DELETE is skipped if the table does not exist yet. pub fn clear_commitment_tree_tables(&self) -> rusqlite::Result<()> { let conn = self.conn.lock().unwrap(); - conn.execute("DELETE FROM commitment_tree_shards", [])?; - conn.execute("DELETE FROM commitment_tree_cap", [])?; - conn.execute("DELETE FROM commitment_tree_checkpoints", [])?; - conn.execute("DELETE FROM commitment_tree_checkpoint_marks_removed", [])?; + for table in &[ + "commitment_tree_shards", + "commitment_tree_cap", + "commitment_tree_checkpoints", + "commitment_tree_checkpoint_marks_removed", + ] { + let exists: bool = conn.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1", + [table], + |row| row.get::<_, i32>(0).map(|c| c > 0), + )?; + if exists { + conn.execute(&format!("DELETE FROM {table}"), [])?; + } + } Ok(()) } From f640aaae9b97949ec734ad1a5c0d23e57f69a729 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:15:19 +0100 Subject: [PATCH 079/147] fix(core): sanitize RPC errors and simplify CoreRpcConnectionFailed SEC-006: Store user-friendly message instead of raw RPC error string in network status when chain lock query fails with a non-auth, non-connection error. CODE-002: Remove redundant detail field from CoreRpcConnectionFailed -- format diagnostic info into the url field instead, keeping source for the error chain. Add SEC-005 INTENTIONAL annotation for RPC errors in status tooltip. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend_task/core/mod.rs | 12 +++++------- src/backend_task/error.rs | 5 +---- src/context/connection_status.rs | 2 ++ src/context/mod.rs | 1 - 4 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/backend_task/core/mod.rs b/src/backend_task/core/mod.rs index 51b2eb943..4437e038b 100644 --- a/src/backend_task/core/mod.rs +++ b/src/backend_task/core/mod.rs @@ -197,8 +197,10 @@ impl AppContext { if let Some(task_err) = Self::chain_lock_rpc_error(active_config, e) { return Err(task_err); } + // Non-auth, non-connection error — log the raw error but show + // a sanitized message in the UI status display. tracing::warn!(network = ?self.network, error = %e, "Chain lock query failed on active network"); - Some(e.to_string()) + Some("RPC error — check Dash Core status".to_string()) } else { None }; @@ -487,13 +489,9 @@ impl AppContext { if is_rpc_connection_error(e) { let url = config .as_ref() - .map(|c| format!("{}:{}", c.core_host, c.core_rpc_port)) + .map(|c| format!("{}:{} ({})", c.core_host, c.core_rpc_port, e)) .unwrap_or_else(|| "unknown".to_string()); - return Some(TaskError::CoreRpcConnectionFailed { - url, - detail: Some(format!("{e}")), - source: None, - }); + return Some(TaskError::CoreRpcConnectionFailed { url, source: None }); } None } diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index 440785d8e..4c2e16c63 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -63,12 +63,10 @@ pub enum TaskError { /// Could not connect to Dash Core at the configured address. #[error( - "Could not connect to Dash Core at {url}.{} Check that Dash Core is running and your network settings are correct.", - detail.as_ref().map(|d| format!(" {d}")).unwrap_or_default() + "Could not connect to Dash Core at {url}. Check that Dash Core is running and your network settings are correct." )] CoreRpcConnectionFailed { url: String, - detail: Option, #[source] source: Option>, }, @@ -1453,7 +1451,6 @@ mod tests { ); let err = TaskError::CoreRpcConnectionFailed { url: "127.0.0.1:9998".to_string(), - detail: None, source: Some(Box::new(dashcore_rpc::Error::JsonRpc( dashcore_rpc::jsonrpc::error::Error::Transport(Box::new(socket_err)), ))), diff --git a/src/context/connection_status.rs b/src/context/connection_status.rs index c242918bb..d69b19352 100644 --- a/src/context/connection_status.rs +++ b/src/context/connection_status.rs @@ -445,6 +445,8 @@ impl ConnectionStatus { devnet_chainlock, local_chainlock, ); + // INTENTIONAL(SEC-005): RPC error strings shown as-is in network + // status tooltip. Acceptable for desktop app — helps debugging. self.set_rpc_last_error(rpc_error.clone()); self.refresh_state(); } diff --git a/src/context/mod.rs b/src/context/mod.rs index f346aa430..a094b6e08 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -649,7 +649,6 @@ impl AppContext { .unwrap_or_else(|| "unknown".to_string()); TaskError::CoreRpcConnectionFailed { url, - detail: None, source: Some(Box::new(e)), } } else { From 6eed03c6a0b628e29123dcccec97b2f4f9866234 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:15:36 +0100 Subject: [PATCH 080/147] fix(ui): config permissions, address detection, and per-frame caching SEC-007: Set config file permissions to 0600 on Unix after save (contains RPC credentials). CODE-003: Deduplicate Amount::dash_from_duffs by delegating to dash_from_credits. CODE-006: Remove unused network parameter from AddressKind::detect -- detection is format-based, network validation happens separately. CODE-004/CODE-005: Cache filtered transaction indices per wallet to avoid rebuilding HashSet and filtering on every frame. Invalidated on wallet switch and refresh. CODE-008: Reset AddressInput widgets on network switch so they pick up the new network for validation. Add invalidate_address_input methods to SendScreen, UnshieldCreditsScreen, and WalletsBalancesScreen. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/config.rs | 10 +++++ src/model/address.rs | 35 ++++++++---------- src/model/amount.rs | 6 +-- src/ui/components/address_input.rs | 4 +- src/ui/mod.rs | 11 +++++- src/ui/wallets/send_screen.rs | 7 +++- src/ui/wallets/unshield_credits_screen.rs | 5 +++ src/ui/wallets/wallets_screen/mod.rs | 45 +++++++++++++++-------- 8 files changed, 80 insertions(+), 43 deletions(-) diff --git a/src/config.rs b/src/config.rs index e33f82224..86c00a777 100644 --- a/src/config.rs +++ b/src/config.rs @@ -191,6 +191,16 @@ impl Config { .persist(&env_file_path) .map_err(|e| ConfigError::SaveError { source: e.error })?; + // 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) { + tracing::warn!("Could not set config file permissions to 0600: {e}"); + } + } + tracing::info!("Successfully saved configuration to {:?}", env_file_path); Ok(()) } diff --git a/src/model/address.rs b/src/model/address.rs index 5a768ab18..7e4180a1e 100644 --- a/src/model/address.rs +++ b/src/model/address.rs @@ -1,5 +1,7 @@ +use dash_sdk::dashcore_rpc::dashcore::Address; +#[cfg(test)] +use dash_sdk::dashcore_rpc::dashcore::Network; use dash_sdk::dashcore_rpc::dashcore::address::NetworkUnchecked; -use dash_sdk::dashcore_rpc::dashcore::{Address, Network}; use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::Identifier; @@ -55,9 +57,10 @@ impl AddressKind { /// Detect the address kind from a raw input string. /// + /// Format-based detection only — network validation happens separately. /// Priority: Shielded > Platform > Core > Identity (Base58 fallback). /// Returns `None` for empty or unrecognized input. - pub fn detect(input: &str, _network: Network) -> Option { + pub fn detect(input: &str) -> Option { let trimmed = input.trim(); if trimmed.is_empty() { return None; @@ -232,14 +235,14 @@ mod tests { #[test] fn detect_empty_returns_none() { - assert_eq!(AddressKind::detect("", Network::Testnet), None); - assert_eq!(AddressKind::detect(" ", Network::Testnet), None); + assert_eq!(AddressKind::detect(""), None); + assert_eq!(AddressKind::detect(" "), None); } #[test] fn detect_shielded_mainnet() { assert_eq!( - AddressKind::detect("dash1z_some_shielded_addr", Network::Mainnet), + AddressKind::detect("dash1z_some_shielded_addr"), Some(AddressKind::Shielded) ); } @@ -247,7 +250,7 @@ mod tests { #[test] fn detect_shielded_testnet() { assert_eq!( - AddressKind::detect("tdash1z_some_shielded_addr", Network::Testnet), + AddressKind::detect("tdash1z_some_shielded_addr"), Some(AddressKind::Shielded) ); } @@ -256,7 +259,7 @@ mod tests { fn detect_shielded_priority_over_platform() { // dash1z starts with "dash1" which could match platform, but shielded wins assert_eq!( - AddressKind::detect("dash1z_test", Network::Mainnet), + AddressKind::detect("dash1z_test"), Some(AddressKind::Shielded) ); } @@ -264,7 +267,7 @@ mod tests { #[test] fn detect_platform_testnet() { assert_eq!( - AddressKind::detect("tdash1qwer1234", Network::Testnet), + AddressKind::detect("tdash1qwer1234"), Some(AddressKind::Platform) ); } @@ -272,7 +275,7 @@ mod tests { #[test] fn detect_platform_mainnet() { assert_eq!( - AddressKind::detect("dash1qwer1234", Network::Mainnet), + AddressKind::detect("dash1qwer1234"), Some(AddressKind::Platform) ); } @@ -288,7 +291,7 @@ mod tests { let pubkey = PublicKey::from_private_key(&secp, &privkey); let addr = Address::p2pkh(&pubkey, Network::Testnet); assert_eq!( - AddressKind::detect(&addr.to_string(), Network::Testnet), + AddressKind::detect(&addr.to_string()), Some(AddressKind::Core) ); } @@ -299,20 +302,14 @@ mod tests { let id_str = id.to_string(Encoding::Base58); // Some random identifiers parse as Core addresses. Skip those for // this test — only assert identity detection for ones that do not. - if AddressKind::detect(&id_str, Network::Testnet) == Some(AddressKind::Core) { + if AddressKind::detect(&id_str) == Some(AddressKind::Core) { return; } - assert_eq!( - AddressKind::detect(&id_str, Network::Testnet), - Some(AddressKind::Identity) - ); + assert_eq!(AddressKind::detect(&id_str), Some(AddressKind::Identity)); } #[test] fn detect_garbage_returns_none() { - assert_eq!( - AddressKind::detect("not-an-address", Network::Testnet), - None - ); + assert_eq!(AddressKind::detect("not-an-address"), None); } } diff --git a/src/model/amount.rs b/src/model/amount.rs index f97997ad8..2c3dd51e8 100644 --- a/src/model/amount.rs +++ b/src/model/amount.rs @@ -320,13 +320,13 @@ impl Amount { /// /// This is a special case where we get Duffs (eg. from Core) and want to convert it to an Amount representing DASH. pub fn dash_from_duffs(duffs: Duffs) -> Self { - let credits = duffs * CREDITS_PER_DUFF; - Self::new(credits, DASH_DECIMAL_PLACES).with_unit_name("DASH") + Self::dash_from_credits(duffs * CREDITS_PER_DUFF) } /// Return Amount representing Dash currency equal to the given credits. /// - /// 1 DASH = 10^11 credits (100 billion). + /// 1 DASH = 10^11 credits (100 billion). Canonical constructor for + /// DASH amounts from the internal credit representation. pub fn dash_from_credits(credits: u64) -> Self { Self::new(credits, DASH_DECIMAL_PLACES).with_unit_name("DASH") } diff --git a/src/ui/components/address_input.rs b/src/ui/components/address_input.rs index b1ca648b6..3e1d84c39 100644 --- a/src/ui/components/address_input.rs +++ b/src/ui/components/address_input.rs @@ -1022,9 +1022,7 @@ impl Component for AddressInput { /// Priority: Shielded > Platform > Core > Identity (Base58 fallback). /// Identity detection only runs when `identity_enabled` is true. fn detect_address_type(input: &str, identity_enabled: bool) -> DetectedType { - // Delegate to AddressKind::detect() with a dummy network (detection is - // network-agnostic — it only checks format, not network correctness). - match AddressKind::detect(input, Network::Testnet) { + match AddressKind::detect(input) { Some(AddressKind::Identity) if !identity_enabled => DetectedType::Unknown, Some(AddressKind::Core) => DetectedType::Core, Some(AddressKind::Platform) => DetectedType::Platform, diff --git a/src/ui/mod.rs b/src/ui/mod.rs index f0ed439d5..502a0b4f1 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -809,12 +809,16 @@ impl Screen { screen.app_context = app_context; screen.reset_pending_list_state(); screen.update_selected_wallet_for_network(); + screen.invalidate_address_inputs(); } Screen::ImportMnemonicScreen(screen) => { screen.app_context = app_context; screen.reset_core_wallets_cache(); } - Screen::WalletSendScreen(screen) => screen.app_context = app_context, + Screen::WalletSendScreen(screen) => { + screen.app_context = app_context; + screen.invalidate_address_input(); + } Screen::SingleKeyWalletSendScreen(screen) => screen.app_context = app_context, Screen::ProofLogScreen(screen) => screen.app_context = app_context, Screen::AddContractsScreen(screen) => screen.app_context = app_context, @@ -872,7 +876,10 @@ impl Screen { Screen::ShieldCreditsScreen(screen) => screen.app_context = app_context.clone(), Screen::ShieldFromAssetLockScreen(screen) => screen.app_context = app_context.clone(), Screen::ShieldedSendScreen(screen) => screen.app_context = app_context.clone(), - Screen::UnshieldCreditsScreen(screen) => screen.app_context = app_context.clone(), + Screen::UnshieldCreditsScreen(screen) => { + screen.app_context = app_context.clone(); + screen.invalidate_address_input(); + } } } } diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index 71b795ca4..45c476a86 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -468,6 +468,11 @@ impl WalletSendScreen { estimate_platform_fee(fee_estimator, usable_count) } + /// Clear the AddressInput widget so it picks up the new network on next frame. + pub(crate) fn invalidate_address_input(&mut self) { + self.address_input = None; + } + fn reset_form(&mut self) { self.address_input = None; self.validated_destination = None; @@ -513,7 +518,7 @@ impl WalletSendScreen { /// /// Returns `None` for empty or unrecognized input. fn detect_address_kind(&self, address: &str) -> Option { - AddressKind::detect(address, self.app_context.network) + AddressKind::detect(address) } fn min_output_amount( diff --git a/src/ui/wallets/unshield_credits_screen.rs b/src/ui/wallets/unshield_credits_screen.rs index cf61de6b0..9fc741ea5 100644 --- a/src/ui/wallets/unshield_credits_screen.rs +++ b/src/ui/wallets/unshield_credits_screen.rs @@ -43,6 +43,11 @@ pub struct UnshieldCreditsScreen { } impl UnshieldCreditsScreen { + /// Clear the AddressInput widget so it picks up the new network on next frame. + pub(crate) fn invalidate_address_input(&mut self) { + self.address_input = None; + } + pub fn new(seed_hash: WalletSeedHash, app_context: &Arc) -> Self { let max_balance = { let states = app_context.shielded_states.lock().unwrap(); diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 4704309c1..1b76aad4a 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -141,6 +141,9 @@ pub struct WalletsBalancesScreen { pending_list_wallet_hash: Option<[u8; 32]>, /// Whether the wallet pending list is a single-key wallet pending_list_is_single_key: bool, + /// Cached filtered transaction indices for the currently selected wallet. + /// Invalidated (set to None) on wallet switch or transaction updates. + cached_tx_indices: Option>, } impl WalletsBalancesScreen { @@ -244,6 +247,7 @@ impl WalletsBalancesScreen { pending_list_core_wallets: false, pending_list_wallet_hash: None, pending_list_is_single_key: false, + cached_tx_indices: None, } } @@ -344,6 +348,7 @@ impl WalletsBalancesScreen { self.selected_account = None; self.selected_tab = WalletViewTab::default(); self.shielded_tab_view = None; + self.cached_tx_indices = None; if let Some(hash) = seed_hash { self.persist_selected_wallet_hash(Some(hash)); @@ -437,6 +442,12 @@ impl WalletsBalancesScreen { self.pending_list_is_single_key = false; } + /// Reset all cached AddressInput widgets so they pick up the new network. + pub(crate) fn invalidate_address_inputs(&mut self) { + self.mine_dialog.address_input = None; + self.cached_tx_indices = None; + } + fn add_receiving_address(&mut self) { if let Some(wallet) = &self.selected_wallet { let result = { @@ -1179,7 +1190,7 @@ impl WalletsBalancesScreen { } } - fn render_transactions_section(&self, ui: &mut Ui) { + fn render_transactions_section(&mut self, ui: &mut Ui) { ui.add_space(10.0); // TODO: Synchronize transactions display with selected account type // (main account -> Core transactions, platform account -> platform state transitions, etc.) @@ -1217,20 +1228,23 @@ impl WalletsBalancesScreen { } // Filter transactions to only those involving this wallet's addresses. - // This prevents showing transactions from other wallets that may have - // leaked in via a non-wallet-scoped RPC endpoint. - let wallet_addresses: std::collections::HashSet<&Address> = - wallet_guard.known_addresses.keys().collect(); - let relevant_indices: Vec = (0..wallet_guard.transactions.len()) - .filter(|&i| { - let tx = &wallet_guard.transactions[i]; - tx.transaction.output.iter().any(|output| { - Address::from_script(&output.script_pubkey, self.app_context.network) - .ok() - .is_some_and(|addr| wallet_addresses.contains(&addr)) + // We check outputs only — transactions are already fetched per-wallet + // from SPV/RPC, so inputs are implicitly relevant. The output filter + // only excludes transactions that leaked from other wallets' data. + let relevant_indices = self.cached_tx_indices.get_or_insert_with(|| { + let wallet_addresses: std::collections::HashSet<&Address> = + wallet_guard.known_addresses.keys().collect(); + (0..wallet_guard.transactions.len()) + .filter(|&i| { + let tx = &wallet_guard.transactions[i]; + tx.transaction.output.iter().any(|output| { + Address::from_script(&output.script_pubkey, self.app_context.network) + .ok() + .is_some_and(|addr| wallet_addresses.contains(&addr)) + }) }) - }) - .collect(); + .collect() + }); if relevant_indices.is_empty() { ui.label( @@ -1240,7 +1254,7 @@ impl WalletsBalancesScreen { } let dark_mode = ui.ctx().style().visuals.dark_mode; - let mut order = relevant_indices; + let mut order: Vec = relevant_indices.clone(); order.sort_by(|&a, &b| { wallet_guard.transactions[b] .timestamp @@ -2443,6 +2457,7 @@ impl ScreenLike for WalletsBalancesScreen { match backend_task_success_result { crate::ui::BackendTaskSuccessResult::RefreshedWallet { warning } => { self.refreshing = false; + self.cached_tx_indices = None; // Refresh the cached platform sync info so the panel shows // updated timestamps and block heights after a wallet sync. let seed_hash = self From 810a686778e67c9b74ee09d783f6217aa017de07 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 17:31:04 +0100 Subject: [PATCH 081/147] fix(logging): correct log levels per coding best practices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - debug! -> trace!: "state transition built, broadcasting" messages in bundle.rs (5 occurrences) — primary-path step-by-step progress - trace! -> debug!: cookie auth fallback in core/mod.rs and context/mod.rs (2 occurrences) — secondary/fallback execution path, not primary flow - info! -> debug!: anchor mismatch retry in context/shielded.rs — error handling branch, not a business event - info! -> debug!: "marked N note(s) spent" in context/shielded.rs — internal bookkeeping, not a user-visible business event - info! -> debug!: post-transfer sync complete in shielded_send_screen.rs — internal plumbing at screen level Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend_task/core/mod.rs | 2 +- src/backend_task/shielded/bundle.rs | 10 +++++----- src/context/mod.rs | 2 +- src/context/shielded.rs | 4 ++-- src/ui/wallets/shielded_send_screen.rs | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/backend_task/core/mod.rs b/src/backend_task/core/mod.rs index 4437e038b..05085e08a 100644 --- a/src/backend_task/core/mod.rs +++ b/src/backend_task/core/mod.rs @@ -454,7 +454,7 @@ impl AppContext { let client = match Client::new(&addr, Auth::CookieFile(cookie_path.clone())) { Ok(client) => client, Err(_) => { - tracing::trace!( + tracing::debug!( "Failed to authenticate using .cookie file at {:?}, falling back to user/pass", cookie_path ); diff --git a/src/backend_task/shielded/bundle.rs b/src/backend_task/shielded/bundle.rs index 54bde394a..994e0c628 100644 --- a/src/backend_task/shielded/bundle.rs +++ b/src/backend_task/shielded/bundle.rs @@ -209,7 +209,7 @@ pub async fn shield_credits( *s.lock().unwrap() = ShieldStage::Broadcasting; } - tracing::debug!("Shield credits: state transition built, broadcasting..."); + tracing::trace!("Shield credits: state transition built, broadcasting..."); state_transition .broadcast(&sdk, None) @@ -307,7 +307,7 @@ pub async fn shielded_transfer( ) .map_err(|e| shielded_build_error(e.to_string()))?; - tracing::debug!("Shielded transfer: state transition built, broadcasting..."); + tracing::trace!("Shielded transfer: state transition built, broadcasting..."); state_transition .broadcast(&sdk, None) @@ -400,7 +400,7 @@ pub async fn unshield_credits( ) .map_err(|e| shielded_build_error(e.to_string()))?; - tracing::debug!("Unshield credits: state transition built, broadcasting..."); + tracing::trace!("Unshield credits: state transition built, broadcasting..."); state_transition .broadcast(&sdk, None) @@ -606,7 +606,7 @@ pub async fn shield_from_asset_lock( ) .map_err(|e| shielded_build_error(e.to_string()))?; - tracing::debug!("Shield from asset lock: state transition built, broadcasting..."); + tracing::trace!("Shield from asset lock: state transition built, broadcasting..."); state_transition .broadcast(&sdk, None) @@ -702,7 +702,7 @@ pub async fn shielded_withdrawal( ) .map_err(|e| shielded_build_error(e.to_string()))?; - tracing::debug!("Shielded withdrawal: state transition built, broadcasting..."); + tracing::trace!("Shielded withdrawal: state transition built, broadcasting..."); state_transition .broadcast(&sdk, None) diff --git a/src/context/mod.rs b/src/context/mod.rs index a094b6e08..8e6e981d7 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -570,7 +570,7 @@ impl AppContext { if let Ok(client) = Client::new(url, Auth::CookieFile(cookie_path.clone())) { return Ok(client); } - tracing::trace!( + tracing::debug!( "Failed to authenticate using .cookie file at {:?}, falling back to user/pass", cookie_path, ); diff --git a/src/context/shielded.rs b/src/context/shielded.rs index 57ffd42db..b91118487 100644 --- a/src/context/shielded.rs +++ b/src/context/shielded.rs @@ -445,7 +445,7 @@ impl AppContext { // Panic during shielded operation loses state for the session — restart recovers. let result = if matches!(result, Err(TaskError::ShieldedAnchorMismatch { .. })) { - tracing::info!( + tracing::debug!( "Shielded anchor mismatch during {operation_name} — syncing notes and retrying" ); let sync_result = crate::backend_task::shielded::sync::sync_notes( @@ -485,7 +485,7 @@ impl AppContext { let notes_before = state.unspent_notes().len(); self.mark_notes_spent(seed_hash, &mut state, spent_nullifiers); let notes_after = state.unspent_notes().len(); - tracing::info!( + tracing::debug!( "Shielded {operation_name}: marked {} note(s) spent (unspent notes: {} -> {}), new balance: {} credits", spent_nullifiers.len(), notes_before, diff --git a/src/ui/wallets/shielded_send_screen.rs b/src/ui/wallets/shielded_send_screen.rs index 2a95ae47e..b23408981 100644 --- a/src/ui/wallets/shielded_send_screen.rs +++ b/src/ui/wallets/shielded_send_screen.rs @@ -238,7 +238,7 @@ impl ScreenLike for ShieldedSendScreen { new_notes, balance, } if seed_hash == self.seed_hash => { - tracing::info!( + tracing::debug!( "ShieldedSendScreen: post-transfer sync complete, new_notes={}, balance={} credits", new_notes, balance, From 801f730b363c9ae32d48c94cdb63a491a7ae69eb Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:24:47 +0100 Subject: [PATCH 082/147] chore: minor ui fixes --- src/ui/wallets/wallets_screen/mod.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index f7a1e5d90..7adfb9654 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -138,8 +138,6 @@ pub struct WalletsBalancesScreen { selected_account_tab: AccountTab, /// Shielded tab view component (lazily initialized per wallet) shielded_tab_view: Option, - /// Whether the balance breakdown section is expanded - balance_breakdown_expanded: bool, /// Cached platform sync info: (last_sync_timestamp, last_sync_height) platform_sync_info: Option<(u64, u64)>, /// Core wallet selection dialog (shown when auto-detection fails) @@ -255,7 +253,6 @@ impl WalletsBalancesScreen { refresh_mode: RefreshMode::default(), selected_account_tab: AccountTab::default(), shielded_tab_view: None, - balance_breakdown_expanded: app_context.is_developer_mode(), platform_sync_info, core_wallet_dialog: None, pending_core_wallet_seed_hash: None, @@ -1328,8 +1325,7 @@ impl WalletsBalancesScreen { let text = if is_selected { RichText::new(&label) .strong() - .underline() - .color(DashColors::DASH_BLUE) + .color(DashColors::text_primary(dark_mode)) } else { RichText::new(&label).color(DashColors::text_secondary(dark_mode)) }; @@ -1978,7 +1974,7 @@ impl WalletsBalancesScreen { .color(DashColors::text_secondary(dark_mode)), ) .id_salt("balance_breakdown") - .default_open(self.balance_breakdown_expanded); + .default_open(false); header.show(ui, |ui| { ui.horizontal(|ui| { From 907cdda0a6f20b79c20cd18de3b3c79b51171e67 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:30:06 +0100 Subject: [PATCH 083/147] feat(ui): replace Advanced dropdown with inline right-aligned buttons Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/wallets_screen/mod.rs | 92 +++++++++++++--------------- 1 file changed, 42 insertions(+), 50 deletions(-) diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 7adfb9654..c15c539c7 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -1073,59 +1073,51 @@ impl WalletsBalancesScreen { ui.add(egui::Spinner::new().color(DashColors::DASH_BLUE)); } - // Advanced dropdown button (developer mode only) if self.app_context.is_developer_mode() { - let advanced_response = ui.button( - RichText::new("Advanced \u{25BC}") - .color(DashColors::text_primary(dark_mode)) - .strong(), - ); - - let popup_id = ui.make_persistent_id("advanced_popup"); - egui::Popup::from_toggle_button_response(&advanced_response) - .id(popup_id) - .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) - .frame(egui::Frame::popup(ui.style()).fill(DashColors::popup_fill(dark_mode))) - .show(|ui| { - ui.set_min_width(160.0); - ui.with_layout(egui::Layout::top_down(egui::Align::RIGHT), |ui| { - // Get Test Dash (opens browser to faucet) - if matches!( - self.app_context.network, - dash_sdk::dpp::dashcore::Network::Testnet - ) && ui.button("Get Test Dash").clicked() - { - ui.ctx().open_url(egui::OpenUrl::new_tab( - "https://faucet.testnet.networks.dash.org/", - )); - } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if matches!( + self.app_context.network, + dash_sdk::dpp::dashcore::Network::Testnet + ) && ui + .button( + RichText::new("Get Test Dash") + .color(DashColors::text_primary(dark_mode)) + .strong(), + ) + .clicked() + { + ui.ctx().open_url(egui::OpenUrl::new_tab( + "https://faucet.testnet.networks.dash.org/", + )); + } - // Mine button (Regtest/Devnet with RPC only) - if matches!( - self.app_context.network, - dash_sdk::dpp::dashcore::Network::Regtest - | dash_sdk::dpp::dashcore::Network::Devnet - ) && self.app_context.core_backend_mode() == CoreBackendMode::Rpc - && ui - .button( - RichText::new("Mine") - .color(DashColors::text_primary(dark_mode)) - .strong(), - ) - .clicked() - { - self.open_mine_dialog(); - } + if matches!( + self.app_context.network, + dash_sdk::dpp::dashcore::Network::Regtest + | dash_sdk::dpp::dashcore::Network::Devnet + ) && self.app_context.core_backend_mode() == CoreBackendMode::Rpc + && ui + .button( + RichText::new("Mine") + .color(DashColors::text_primary(dark_mode)) + .strong(), + ) + .clicked() + { + self.open_mine_dialog(); + } - // Refresh Mode cycle button - if ui - .button(format!("Refresh mode: {}", self.refresh_mode.label())) - .clicked() - { - self.refresh_mode = self.refresh_mode.next(); - } - }); - }); + if ui + .button( + RichText::new(format!("Refresh mode: {}", self.refresh_mode.label())) + .color(DashColors::text_primary(dark_mode)) + .strong(), + ) + .clicked() + { + self.refresh_mode = self.refresh_mode.next(); + } + }); } }); From 21882ced14e296bd979df0f47fdd2b000acc9c8c Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:36:40 +0100 Subject: [PATCH 084/147] fix(ui): use allocate_ui_with_layout to right-align dev buttons The right_to_left layout inside with_layout only got the remaining space after left buttons, centering the dev buttons instead of pushing them to the right edge. Using allocate_ui_with_layout with the full remaining width forces the right-to-left block to span the entire remaining area. Co-Authored-By: Claude Opus 4.6 --- src/ui/wallets/wallets_screen/mod.rs | 81 +++++++++++++++------------- 1 file changed, 45 insertions(+), 36 deletions(-) diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index c15c539c7..cb71d04ad 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -1073,51 +1073,60 @@ impl WalletsBalancesScreen { ui.add(egui::Spinner::new().color(DashColors::DASH_BLUE)); } + // Dev-mode buttons: right-aligned, filling all remaining space if self.app_context.is_developer_mode() { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if matches!( - self.app_context.network, - dash_sdk::dpp::dashcore::Network::Testnet - ) && ui - .button( - RichText::new("Get Test Dash") - .color(DashColors::text_primary(dark_mode)) - .strong(), - ) - .clicked() - { - ui.ctx().open_url(egui::OpenUrl::new_tab( - "https://faucet.testnet.networks.dash.org/", - )); - } - - if matches!( - self.app_context.network, - dash_sdk::dpp::dashcore::Network::Regtest - | dash_sdk::dpp::dashcore::Network::Devnet - ) && self.app_context.core_backend_mode() == CoreBackendMode::Rpc - && ui + let remaining = ui.available_width(); + ui.allocate_ui_with_layout( + egui::vec2(remaining, ui.min_size().y), + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + if matches!( + self.app_context.network, + dash_sdk::dpp::dashcore::Network::Testnet + ) && ui .button( - RichText::new("Mine") + RichText::new("Get Test Dash") .color(DashColors::text_primary(dark_mode)) .strong(), ) .clicked() - { - self.open_mine_dialog(); - } + { + ui.ctx().open_url(egui::OpenUrl::new_tab( + "https://faucet.testnet.networks.dash.org/", + )); + } - if ui - .button( - RichText::new(format!("Refresh mode: {}", self.refresh_mode.label())) + if matches!( + self.app_context.network, + dash_sdk::dpp::dashcore::Network::Regtest + | dash_sdk::dpp::dashcore::Network::Devnet + ) && self.app_context.core_backend_mode() == CoreBackendMode::Rpc + && ui + .button( + RichText::new("Mine") + .color(DashColors::text_primary(dark_mode)) + .strong(), + ) + .clicked() + { + self.open_mine_dialog(); + } + + if ui + .button( + RichText::new(format!( + "Refresh mode: {}", + self.refresh_mode.label() + )) .color(DashColors::text_primary(dark_mode)) .strong(), - ) - .clicked() - { - self.refresh_mode = self.refresh_mode.next(); - } - }); + ) + .clicked() + { + self.refresh_mode = self.refresh_mode.next(); + } + }, + ); } }); From b52369e432ba2faea89fba8043e0dc53c8c92ced Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:38:45 +0100 Subject: [PATCH 085/147] fix(ui): eagerly initialize shielded balance on wallet screen open The shielded tab balance only recalculated when the user clicked the Shielded tab because ShieldedTabView was lazily initialized on tab selection. This meant the wallet list and tab header showed zero balance until the user manually visited the tab. Two-layer fix: - Backend: call initialize_shielded_wallet() in handle_wallet_unlocked() so persisted shielded balance is loaded into shielded_states at wallet load time, making it available to all UI screens immediately. - UI: eagerly create ShieldedTabView on wallet screen construction and wallet switch, and tick() it every frame so the init/sync chain (InitializeShieldedWallet -> SyncNotes -> CheckNullifiers) runs even when the Shielded tab is not the active tab. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/context/shielded.rs | 2 +- src/context/wallet_lifecycle.rs | 15 ++++++++ src/ui/wallets/shielded_tab.rs | 57 ++++++++++++++++------------ src/ui/wallets/wallets_screen/mod.rs | 25 +++++++++++- 4 files changed, 72 insertions(+), 27 deletions(-) diff --git a/src/context/shielded.rs b/src/context/shielded.rs index 57ffd42db..98e070076 100644 --- a/src/context/shielded.rs +++ b/src/context/shielded.rs @@ -150,7 +150,7 @@ impl AppContext { } /// Initialize shielded wallet state by deriving ZIP32 keys from the wallet seed. - fn initialize_shielded_wallet( + pub(crate) fn initialize_shielded_wallet( self: &Arc, seed_hash: WalletSeedHash, ) -> Result { diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index e91216933..08bc52b0d 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -150,6 +150,21 @@ impl AppContext { self.queue_spv_wallet_load(seed_hash, seed_bytes); // 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. + match self.initialize_shielded_wallet(seed_hash) { + Ok(_) => tracing::trace!( + seed = %hex::encode(seed_hash), + "Shielded wallet state initialized on unlock" + ), + Err(e) => tracing::debug!( + seed = %hex::encode(seed_hash), + error = %e, + "Shielded wallet init skipped on unlock" + ), + } } } diff --git a/src/ui/wallets/shielded_tab.rs b/src/ui/wallets/shielded_tab.rs index 495b2cee2..70bbf2f0f 100644 --- a/src/ui/wallets/shielded_tab.rs +++ b/src/ui/wallets/shielded_tab.rs @@ -74,6 +74,38 @@ impl ShieldedTabView { self.app_context = app_context.clone(); } + /// Drain pending backend tasks and trigger auto-initialization without + /// rendering any UI. Call this every frame so the init/sync chain runs + /// even when the Shielded tab is not the active tab. + pub fn tick(&mut self) -> AppAction { + let mut action = self + .pending_task + .take() + .map(AppAction::BackendTask) + .unwrap_or(AppAction::None); + + // Auto-initialize if the wallet is already open (mirrors the check in ui()) + if !self.is_initialized && !self.initializing { + let wallet_arc = { + let wallets = self.app_context.wallets.read().unwrap(); + wallets.get(&self.seed_hash).cloned() + }; + if let Some(wallet) = &wallet_arc + && !wallet_needs_unlock(wallet) + { + let _ = try_open_wallet_no_password(wallet); + self.initializing = true; + action |= AppAction::BackendTask(BackendTask::ShieldedTask( + ShieldedTask::InitializeShieldedWallet { + seed_hash: self.seed_hash, + }, + )); + } + } + + action + } + /// Handle backend task results for shielded operations. pub fn handle_result( &mut self, @@ -176,11 +208,7 @@ impl ShieldedTabView { /// Render the shielded tab content. pub fn ui(&mut self, ui: &mut Ui) -> AppAction { let dark_mode = ui.ctx().style().visuals.dark_mode; - let mut action = self - .pending_task - .take() - .map(AppAction::BackendTask) - .unwrap_or(AppAction::None); + let mut action = self.tick(); // Messages if let Some(err) = &self.error_message.clone() { @@ -216,25 +244,6 @@ impl ShieldedTabView { } // --- Not yet initialized --- - // Auto-initialize if the wallet is already open (no user click needed) - if !self.is_initialized && !self.initializing { - let wallet_arc = { - let wallets = self.app_context.wallets.read().unwrap(); - wallets.get(&self.seed_hash).cloned() - }; - if let Some(wallet) = &wallet_arc - && !wallet_needs_unlock(wallet) - { - let _ = try_open_wallet_no_password(wallet); - self.initializing = true; - action |= AppAction::BackendTask(BackendTask::ShieldedTask( - ShieldedTask::InitializeShieldedWallet { - seed_hash: self.seed_hash, - }, - )); - } - } - if !self.is_initialized { if self.initializing { ui.horizontal(|ui| { diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index cb71d04ad..18dfebbff 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -222,6 +222,13 @@ impl WalletsBalancesScreen { .and_then(|hash| app_context.db.get_platform_sync_info(&hash).ok()) .filter(|(ts, _)| *ts > 0); + // Eagerly create the ShieldedTabView so the init/sync chain starts + // as soon as the wallet screen is constructed. + let shielded_tab_view = selected_wallet + .as_ref() + .and_then(|w| w.read().ok().map(|g| g.seed_hash())) + .map(|hash| ShieldedTabView::new(app_context, hash)); + Self { selected_wallet, selected_single_key_wallet, @@ -252,7 +259,8 @@ impl WalletsBalancesScreen { utxo_page: 0, refresh_mode: RefreshMode::default(), selected_account_tab: AccountTab::default(), - shielded_tab_view: None, + shielded_tab_view, + balance_breakdown_expanded: app_context.is_developer_mode(), platform_sync_info, core_wallet_dialog: None, pending_core_wallet_seed_hash: None, @@ -362,9 +370,13 @@ impl WalletsBalancesScreen { self.selected_single_key_wallet = None; self.selected_account = None; self.selected_account_tab = AccountTab::default(); - self.shielded_tab_view = None; self.cached_tx_indices = None; + // Eagerly create the ShieldedTabView so the init/sync chain starts + // immediately on wallet switch, not only when the user clicks the tab. + self.shielded_tab_view = + seed_hash.map(|hash| ShieldedTabView::new(&self.app_context, hash)); + if let Some(hash) = seed_hash { self.persist_selected_wallet_hash(Some(hash)); self.refresh_platform_sync_info_cache(&hash); @@ -2235,6 +2247,14 @@ impl ScreenLike for WalletsBalancesScreen { AppAction::None }; + // Tick the shielded tab view every frame so the init/sync chain + // runs even when the Shielded tab is not the active tab. + let shielded_tick_action = self + .shielded_tab_view + .as_mut() + .map(|v| v.tick()) + .unwrap_or(AppAction::None); + let mut right_buttons = vec![ ( "Import Wallet", @@ -2702,6 +2722,7 @@ impl ScreenLike for WalletsBalancesScreen { // Combine with pending actions action |= pending_refresh_action; action |= pending_switch_action; + action |= shielded_tick_action; action } From bdb48aa09b3331d904cd9c229b4024115a4f721b Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:41:09 +0100 Subject: [PATCH 086/147] fix: remove stale balance_breakdown_expanded field from cherry-pick Co-Authored-By: Claude Opus 4.6 --- src/ui/wallets/wallets_screen/mod.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 18dfebbff..cf2398643 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -260,7 +260,6 @@ impl WalletsBalancesScreen { refresh_mode: RefreshMode::default(), selected_account_tab: AccountTab::default(), shielded_tab_view, - balance_breakdown_expanded: app_context.is_developer_mode(), platform_sync_info, core_wallet_dialog: None, pending_core_wallet_seed_hash: None, From d4888920ca0a4e0e6b3713b718ba5cc2f900d721 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:41:53 +0100 Subject: [PATCH 087/147] fix(ui): move action buttons to full-width row below header columns The button bar was inside the left column (55% width), so the right-aligned dev buttons ended up in the middle of the screen. Move render_action_buttons() below the two-column header so it spans the full available width. Co-Authored-By: Claude Opus 4.6 --- src/ui/wallets/wallets_screen/mod.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index cf2398643..00dfd54f9 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -2033,7 +2033,7 @@ impl WalletsBalancesScreen { let right_width = available - left_width; ui.horizontal(|ui| { - // LEFT COLUMN: name, total balance, action buttons + // LEFT COLUMN: name, total balance ui.vertical(|ui| { ui.set_width(left_width); @@ -2058,9 +2058,6 @@ impl WalletsBalancesScreen { let wallet = wallet_arc.read().unwrap(); self.render_balance_total(ui, &wallet); } - - // Action buttons (Send, Receive, spinner, Advanced) - action |= self.render_action_buttons(ui, ctx); }); // RIGHT COLUMN: balance breakdown + sync status, right-aligned @@ -2078,6 +2075,9 @@ impl WalletsBalancesScreen { }); }); + // Action buttons span full width below the header + action |= self.render_action_buttons(ui, ctx); + // --- Accounts & Addresses (tabs, full-width below header) --- ui.add_space(10.0); ui.separator(); From e693f15e5e381a27709a70a1e3e6f0062340b9d0 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:45:03 +0100 Subject: [PATCH 088/147] refactor(ui): change sync status to bullet-point layout Replace the wide horizontal Platform line (pipe-separated) with individual bullet-point items: Core, Addresses, Notes, Nullifiers each on their own line. Removes the Frame wrapper for a lighter appearance. Co-Authored-By: Claude Opus 4.6 --- src/ui/wallets/wallets_screen/mod.rs | 415 ++++++++++++--------------- 1 file changed, 181 insertions(+), 234 deletions(-) diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 00dfd54f9..b247301f6 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -1702,257 +1702,204 @@ impl WalletsBalancesScreen { /// Render a compact sync status panel showing Core, Platform, and Shielded sync progress. fn render_sync_status(&self, ui: &mut Ui) { let dark_mode = ui.ctx().style().visuals.dark_mode; + let secondary = DashColors::text_secondary(dark_mode); + let syncing_color = DashColors::DASH_BLUE; + let sz = 12.0; ui.collapsing( - RichText::new("Sync Status") - .size(12.0) - .color(DashColors::text_secondary(dark_mode)), + RichText::new("Sync Status").size(sz).color(secondary), |ui| { - Frame::group(ui.style()) - .fill(DashColors::surface(dark_mode)) - .inner_margin(Margin::symmetric(16, 8)) - .show(ui, |ui| { - // Line 1 -- Core sync status - ui.horizontal(|ui| { - ui.label( - RichText::new("Core:") - .size(12.0) - .strong() - .color(DashColors::text_primary(dark_mode)), - ); - - match self.app_context.core_backend_mode() { - CoreBackendMode::Rpc => { - if self.app_context.connection_status().rpc_online() { - ui.colored_label( - Color32::DARK_GREEN, - RichText::new("Connected").size(12.0), - ); - } else { - ui.colored_label( - DashColors::ERROR, - RichText::new("Disconnected").size(12.0), - ); - } - } - CoreBackendMode::Spv => { - let snapshot = self.app_context.spv_manager().status(); - match snapshot.status { - SpvStatus::Idle | SpvStatus::Stopped => { - ui.label( - RichText::new("Disconnected") - .size(12.0) - .color(DashColors::text_secondary(dark_mode)), - ); - } - SpvStatus::Starting => { - ui.add( - egui::Spinner::new() - .size(12.0) - .color(DashColors::DASH_BLUE), - ); - ui.label( - RichText::new("Connecting...") - .size(12.0) - .color(DashColors::DASH_BLUE), - ); - } - SpvStatus::Syncing => { - ui.add( - egui::Spinner::new() - .size(12.0) - .color(DashColors::DASH_BLUE), - ); - let phase_text = snapshot - .sync_progress - .as_ref() - .map(spv_phase_summary) - .unwrap_or_else(|| "starting...".to_string()); - ui.label( - RichText::new(format!("Syncing — {phase_text}")) - .size(12.0) - .color(DashColors::DASH_BLUE), - ); - } - SpvStatus::Running => { - ui.colored_label( - Color32::DARK_GREEN, - RichText::new(format!( - "Synced — {} peers", - snapshot.connected_peers - )) - .size(12.0), - ); - } - SpvStatus::Stopping => { - ui.add( - egui::Spinner::new() - .size(12.0) - .color(DashColors::DASH_BLUE), - ); - ui.label( - RichText::new("Disconnecting...") - .size(12.0) - .color(DashColors::DASH_BLUE), - ); - } - SpvStatus::Error => { - ui.colored_label( - DashColors::ERROR, - RichText::new("Error").size(12.0), - ); - } - } - } - } - }); - - // Line 2 -- Platform sync status - ui.horizontal(|ui| { - ui.label( - RichText::new("Platform:") - .size(12.0) - .strong() - .color(DashColors::text_primary(dark_mode)), - ); - - // Addresses - let addr_count = self - .selected_wallet - .as_ref() - .and_then(|w| w.read().ok()) - .map(|w| w.platform_address_info.len()) - .unwrap_or(0); - if self.refreshing { - ui.add( - egui::Spinner::new().size(12.0).color(DashColors::DASH_BLUE), + // -- Core sync status -- + ui.horizontal(|ui| { + ui.label(RichText::new("•").size(sz).color(secondary)); + ui.label( + RichText::new("Core:") + .size(sz) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + match self.app_context.core_backend_mode() { + CoreBackendMode::Rpc => { + if self.app_context.connection_status().rpc_online() { + ui.colored_label( + Color32::DARK_GREEN, + RichText::new("Connected").size(sz), ); - } - let addr_text = if let Some((last_sync_ts, sync_height)) = - self.platform_sync_info - { - let ago = Self::format_unix_time_ago(last_sync_ts); - format!( - "Addresses: {} synced (blk {}, {})", - addr_count, sync_height, ago - ) } else { - "Addresses: never synced".to_string() - }; - ui.label(RichText::new(addr_text).size(12.0).color( - if self.refreshing { - DashColors::DASH_BLUE - } else { - DashColors::text_secondary(dark_mode) - }, - )); - - ui.label( - RichText::new("|") - .size(12.0) - .color(DashColors::text_secondary(dark_mode)), - ); - - // Shielded notes + nullifiers - let seed_hash = self - .selected_wallet - .as_ref() - .and_then(|w| w.read().ok().map(|g| g.seed_hash())); - let shielded_info = seed_hash.and_then(|hash| { - let states = self.app_context.shielded_states.lock().ok()?; - let state = states.get(&hash)?; - Some(( - state.last_synced_index, - state.notes.iter().filter(|n| !n.is_spent).count(), - state.last_nullifier_sync_height, - state.last_notes_synced_at, - state.last_nullifiers_synced_at, - )) - }); - let shielded_syncing = self - .shielded_tab_view - .as_ref() - .is_some_and(|v| v.is_syncing()); - - match shielded_info { - Some(( - synced_index, - note_count, - nf_height, - notes_synced_at, - nf_synced_at, - )) => { - if shielded_syncing { - ui.add( - egui::Spinner::new() - .size(12.0) - .color(DashColors::DASH_BLUE), - ); - } - let notes_text = if let Some(t) = notes_synced_at { - let ago = Self::format_instant_ago(t); - format!( - "Notes: {} synced ({} notes, {})", - synced_index, note_count, ago - ) - } else if synced_index > 0 { - format!( - "Notes: {} synced ({} notes)", - synced_index, note_count - ) - } else { - "Notes: never synced".to_string() - }; - ui.label(RichText::new(notes_text).size(12.0).color( - if shielded_syncing { - DashColors::DASH_BLUE - } else { - DashColors::text_secondary(dark_mode) - }, - )); - + ui.colored_label( + DashColors::ERROR, + RichText::new("Disconnected").size(sz), + ); + } + } + CoreBackendMode::Spv => { + let snapshot = self.app_context.spv_manager().status(); + match snapshot.status { + SpvStatus::Idle | SpvStatus::Stopped => { ui.label( - RichText::new("|") - .size(12.0) - .color(DashColors::text_secondary(dark_mode)), + RichText::new("Disconnected").size(sz).color(secondary), ); - - let nf_text = if let Some(t) = nf_synced_at { - let ago = Self::format_instant_ago(t); - format!("Nullifiers: height {} ({})", nf_height, ago) - } else if nf_height > 0 { - format!("Nullifiers: height {}", nf_height) - } else { - "Nullifiers: never synced".to_string() - }; - ui.label(RichText::new(nf_text).size(12.0).color( - if shielded_syncing { - DashColors::DASH_BLUE - } else { - DashColors::text_secondary(dark_mode) - }, - )); } - None => { + SpvStatus::Starting => { + ui.add(egui::Spinner::new().size(sz).color(syncing_color)); ui.label( - RichText::new("Notes: never synced") - .size(12.0) - .color(DashColors::text_secondary(dark_mode)), + RichText::new("Connecting...") + .size(sz) + .color(syncing_color), ); + } + SpvStatus::Syncing => { + ui.add(egui::Spinner::new().size(sz).color(syncing_color)); + let phase_text = snapshot + .sync_progress + .as_ref() + .map(spv_phase_summary) + .unwrap_or_else(|| "starting...".to_string()); ui.label( - RichText::new("|") - .size(12.0) - .color(DashColors::text_secondary(dark_mode)), + RichText::new(format!("Syncing — {phase_text}")) + .size(sz) + .color(syncing_color), ); + } + SpvStatus::Running => { + ui.colored_label( + Color32::DARK_GREEN, + RichText::new(format!( + "Synced — {} peers", + snapshot.connected_peers + )) + .size(sz), + ); + } + SpvStatus::Stopping => { + ui.add(egui::Spinner::new().size(sz).color(syncing_color)); ui.label( - RichText::new("Nullifiers: never synced") - .size(12.0) - .color(DashColors::text_secondary(dark_mode)), + RichText::new("Disconnecting...") + .size(sz) + .color(syncing_color), + ); + } + SpvStatus::Error => { + ui.colored_label( + DashColors::ERROR, + RichText::new("Error").size(sz), ); } } + } + } + }); + + // -- Platform: Addresses -- + let addr_count = self + .selected_wallet + .as_ref() + .and_then(|w| w.read().ok()) + .map(|w| w.platform_address_info.len()) + .unwrap_or(0); + let addr_color = if self.refreshing { + syncing_color + } else { + secondary + }; + ui.horizontal(|ui| { + ui.label(RichText::new("•").size(sz).color(secondary)); + if self.refreshing { + ui.add(egui::Spinner::new().size(sz).color(syncing_color)); + } + let addr_text = + if let Some((last_sync_ts, sync_height)) = self.platform_sync_info { + let ago = Self::format_unix_time_ago(last_sync_ts); + format!( + "Addresses: {} synced (blk {}, {})", + addr_count, sync_height, ago + ) + } else { + "Addresses: never synced".to_string() + }; + ui.label(RichText::new(addr_text).size(sz).color(addr_color)); + }); + + // -- Shielded: Notes + Nullifiers -- + let seed_hash = self + .selected_wallet + .as_ref() + .and_then(|w| w.read().ok().map(|g| g.seed_hash())); + let shielded_info = seed_hash.and_then(|hash| { + let states = self.app_context.shielded_states.lock().ok()?; + let state = states.get(&hash)?; + Some(( + state.last_synced_index, + state.notes.iter().filter(|n| !n.is_spent).count(), + state.last_nullifier_sync_height, + state.last_notes_synced_at, + state.last_nullifiers_synced_at, + )) + }); + let shielded_syncing = self + .shielded_tab_view + .as_ref() + .is_some_and(|v| v.is_syncing()); + let shielded_color = if shielded_syncing { + syncing_color + } else { + secondary + }; + + match shielded_info { + Some((synced_index, note_count, nf_height, notes_synced_at, nf_synced_at)) => { + // Notes bullet + ui.horizontal(|ui| { + ui.label(RichText::new("•").size(sz).color(secondary)); + if shielded_syncing { + ui.add(egui::Spinner::new().size(sz).color(syncing_color)); + } + let notes_text = if let Some(t) = notes_synced_at { + let ago = Self::format_instant_ago(t); + format!( + "Notes: {} synced ({} notes, {})", + synced_index, note_count, ago + ) + } else if synced_index > 0 { + format!("Notes: {} synced ({} notes)", synced_index, note_count) + } else { + "Notes: never synced".to_string() + }; + ui.label(RichText::new(notes_text).size(sz).color(shielded_color)); }); - }); + // Nullifiers bullet + ui.horizontal(|ui| { + ui.label(RichText::new("•").size(sz).color(secondary)); + let nf_text = if let Some(t) = nf_synced_at { + let ago = Self::format_instant_ago(t); + format!("Nullifiers: height {} ({})", nf_height, ago) + } else if nf_height > 0 { + format!("Nullifiers: height {}", nf_height) + } else { + "Nullifiers: never synced".to_string() + }; + ui.label(RichText::new(nf_text).size(sz).color(shielded_color)); + }); + } + None => { + ui.horizontal(|ui| { + ui.label(RichText::new("•").size(sz).color(secondary)); + ui.label( + RichText::new("Notes: never synced") + .size(sz) + .color(secondary), + ); + }); + ui.horizontal(|ui| { + ui.label(RichText::new("•").size(sz).color(secondary)); + ui.label( + RichText::new("Nullifiers: never synced") + .size(sz) + .color(secondary), + ); + }); + } + } }, ); } From 54609a9da40e1015196a9a0734bbaa06be324f57 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:48:29 +0100 Subject: [PATCH 089/147] fix(shielded): prevent double balance from redundant init + sync chain When eager init (wallet_lifecycle) populates shielded_states and then ShieldedTabView.tick() dispatches another InitializeShieldedWallet, the idempotency check returns early but handle_result() chains SyncNotes which can re-append notes already loaded from DB. Fix: tick() now checks if shielded_states already contains the wallet's state. If so, it adopts the cached balance and marks itself initialized without dispatching a backend task, preventing the redundant sync chain. Co-Authored-By: Claude Opus 4.6 --- src/ui/wallets/shielded_tab.rs | 54 ++++++++++++++++++++++++---------- 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/src/ui/wallets/shielded_tab.rs b/src/ui/wallets/shielded_tab.rs index 70bbf2f0f..9539d6064 100644 --- a/src/ui/wallets/shielded_tab.rs +++ b/src/ui/wallets/shielded_tab.rs @@ -84,22 +84,46 @@ impl ShieldedTabView { .map(AppAction::BackendTask) .unwrap_or(AppAction::None); - // Auto-initialize if the wallet is already open (mirrors the check in ui()) + // Auto-initialize if the wallet is already open (mirrors the check in ui()). + // If the backend already initialized the state eagerly (e.g. in + // handle_wallet_unlocked), skip dispatching another init task to avoid + // a redundant SyncNotes that can re-append notes already loaded from DB. if !self.is_initialized && !self.initializing { - let wallet_arc = { - let wallets = self.app_context.wallets.read().unwrap(); - wallets.get(&self.seed_hash).cloned() - }; - if let Some(wallet) = &wallet_arc - && !wallet_needs_unlock(wallet) - { - let _ = try_open_wallet_no_password(wallet); - self.initializing = true; - action |= AppAction::BackendTask(BackendTask::ShieldedTask( - ShieldedTask::InitializeShieldedWallet { - seed_hash: self.seed_hash, - }, - )); + let already_in_state = self + .app_context + .shielded_states + .lock() + .ok() + .is_some_and(|states| states.contains_key(&self.seed_hash)); + if already_in_state { + // State was populated eagerly — adopt the cached balance and + // mark as initialized without dispatching a backend task. + self.is_initialized = true; + if let Some(balance) = self + .app_context + .shielded_states + .lock() + .ok() + .and_then(|states| states.get(&self.seed_hash).map(|s| s.shielded_balance)) + { + self.shielded_balance = balance; + } + } else { + let wallet_arc = { + let wallets = self.app_context.wallets.read().unwrap(); + wallets.get(&self.seed_hash).cloned() + }; + if let Some(wallet) = &wallet_arc + && !wallet_needs_unlock(wallet) + { + let _ = try_open_wallet_no_password(wallet); + self.initializing = true; + action |= AppAction::BackendTask(BackendTask::ShieldedTask( + ShieldedTask::InitializeShieldedWallet { + seed_hash: self.seed_hash, + }, + )); + } } } From 24a3521df5925631486be48b2126db6a4e41ec85 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:59:02 +0100 Subject: [PATCH 090/147] refactor(shielded): move initialization entirely to backend, remove UI init path The shielded wallet had two initialization paths: the backend (handle_wallet_unlocked -> initialize_shielded_wallet) and the UI (ShieldedTabView::tick dispatching InitializeShieldedWallet). This created redundant init attempts and blurred the responsibility boundary. Now: - handle_wallet_unlocked initializes the shielded state synchronously, then queues SyncNotes -> CheckNullifiers as a background task - ShieldedTabView reads its state from AppContext::shielded_states via refresh_from_backend_state(), never dispatching init itself - The "Initialize Shielded Wallet" button and WalletUnlockPopup are removed from ShieldedTabView (wallet unlock already calls handle_wallet_unlocked which handles everything) - Resync Notes (developer mode) still dispatches InitializeShieldedWallet as an explicit user action Co-Authored-By: Claude Opus 4.6 (1M context) --- src/context/shielded.rs | 4 +- src/context/wallet_lifecycle.rs | 42 +++++- src/ui/wallets/shielded_tab.rs | 184 ++++++++------------------- src/ui/wallets/wallets_screen/mod.rs | 8 +- 4 files changed, 96 insertions(+), 142 deletions(-) diff --git a/src/context/shielded.rs b/src/context/shielded.rs index 98e070076..ca5b902f9 100644 --- a/src/context/shielded.rs +++ b/src/context/shielded.rs @@ -277,7 +277,7 @@ impl AppContext { } /// Sync shielded notes from platform. - async fn sync_shielded_notes( + pub(crate) async fn sync_shielded_notes( self: &Arc, seed_hash: WalletSeedHash, ) -> Result { @@ -539,7 +539,7 @@ impl AppContext { } /// Check nullifiers to detect spent notes. - async fn check_nullifiers_task( + pub(crate) async fn check_nullifiers_task( self: &Arc, seed_hash: WalletSeedHash, ) -> Result { diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index 08bc52b0d..6ea6478a5 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -154,11 +154,17 @@ impl AppContext { // 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!( - seed = %hex::encode(seed_hash), - "Shielded wallet state initialized on unlock" - ), + 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), error = %e, @@ -179,6 +185,34 @@ impl AppContext { self.queue_spv_wallet_unload(seed_hash); } + /// Queue async SyncNotes -> CheckNullifiers for an already-initialized + /// shielded wallet. Uses `spawn_blocking` + `block_on` to sidestep + /// rust-lang/rust#100013 (`self: &Arc` futures are not Send). + fn queue_shielded_sync(self: &Arc, seed_hash: WalletSeedHash) { + let ctx = Arc::clone(self); + let handle = tokio::runtime::Handle::current(); + tokio::task::spawn_blocking(move || { + handle.block_on(async { + match ctx.sync_shielded_notes(seed_hash).await { + Ok(_) => { + if let Err(e) = ctx.check_nullifiers_task(seed_hash).await { + tracing::debug!( + seed = %hex::encode(seed_hash), + error = %e, + "Shielded nullifier check after init failed" + ); + } + } + Err(e) => tracing::debug!( + seed = %hex::encode(seed_hash), + error = %e, + "Shielded note sync after init failed" + ), + } + }); + }); + } + fn wallet_seed_snapshot(wallet: &Arc>) -> Option<(WalletSeedHash, [u8; 64])> { let guard = wallet.read().ok()?; if !guard.is_open() { diff --git a/src/ui/wallets/shielded_tab.rs b/src/ui/wallets/shielded_tab.rs index 9539d6064..b228a74a1 100644 --- a/src/ui/wallets/shielded_tab.rs +++ b/src/ui/wallets/shielded_tab.rs @@ -4,9 +4,7 @@ use crate::backend_task::shielded::ShieldedTask; use crate::context::AppContext; use crate::model::wallet::WalletSeedHash; use crate::ui::ScreenType; -use crate::ui::components::wallet_unlock_popup::{ - WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, -}; +use crate::ui::components::wallet_unlock_popup::wallet_needs_unlock; use crate::ui::helpers::copy_text_to_clipboard; use crate::ui::theme::DashColors; use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; @@ -26,10 +24,8 @@ pub struct ShieldedTabView { is_initialized: bool, /// Whether the commitment tree has been synced (enables spend operations). tree_synced: bool, - /// Pending backend task to dispatch on next ui() call (e.g., auto-sync after init). + /// Pending backend task to dispatch on next ui() call (e.g., sync after Resync). pending_task: Option, - /// Wallet unlock popup for the initialize flow. - wallet_unlock_popup: WalletUnlockPopup, /// Currently selected diversified address index. selected_address_index: u32, /// Number of diversified addresses generated (always >= 1). @@ -49,7 +45,6 @@ impl ShieldedTabView { is_initialized: false, tree_synced: false, pending_task: None, - wallet_unlock_popup: WalletUnlockPopup::new(), selected_address_index: 0, address_count: 1, } @@ -74,60 +69,35 @@ impl ShieldedTabView { self.app_context = app_context.clone(); } - /// Drain pending backend tasks and trigger auto-initialization without - /// rendering any UI. Call this every frame so the init/sync chain runs - /// even when the Shielded tab is not the active tab. + /// Drain pending backend tasks (from explicit user actions like Resync). + /// Initialization is handled entirely by the backend in + /// `handle_wallet_unlocked` — the UI never triggers it. pub fn tick(&mut self) -> AppAction { - let mut action = self - .pending_task + self.refresh_from_backend_state(); + + self.pending_task .take() .map(AppAction::BackendTask) - .unwrap_or(AppAction::None); - - // Auto-initialize if the wallet is already open (mirrors the check in ui()). - // If the backend already initialized the state eagerly (e.g. in - // handle_wallet_unlocked), skip dispatching another init task to avoid - // a redundant SyncNotes that can re-append notes already loaded from DB. - if !self.is_initialized && !self.initializing { - let already_in_state = self - .app_context - .shielded_states - .lock() - .ok() - .is_some_and(|states| states.contains_key(&self.seed_hash)); - if already_in_state { - // State was populated eagerly — adopt the cached balance and - // mark as initialized without dispatching a backend task. - self.is_initialized = true; - if let Some(balance) = self - .app_context - .shielded_states - .lock() - .ok() - .and_then(|states| states.get(&self.seed_hash).map(|s| s.shielded_balance)) - { - self.shielded_balance = balance; - } - } else { - let wallet_arc = { - let wallets = self.app_context.wallets.read().unwrap(); - wallets.get(&self.seed_hash).cloned() - }; - if let Some(wallet) = &wallet_arc - && !wallet_needs_unlock(wallet) - { - let _ = try_open_wallet_no_password(wallet); - self.initializing = true; - action |= AppAction::BackendTask(BackendTask::ShieldedTask( - ShieldedTask::InitializeShieldedWallet { - seed_hash: self.seed_hash, - }, - )); - } + .unwrap_or(AppAction::None) + } + + /// Sync local display state from `AppContext::shielded_states`. + fn refresh_from_backend_state(&mut self) { + if let Ok(states) = self.app_context.shielded_states.lock() + && let Some(state) = states.get(&self.seed_hash) + { + self.is_initialized = true; + self.shielded_balance = state.shielded_balance; + // The background sync chain (SyncNotes -> CheckNullifiers) runs + // outside the UI task system. Derive tree_synced from state so + // spend buttons become enabled after the backend finishes. + if state.last_notes_synced_at.is_some() { + self.tree_synced = true; + } + if state.last_nullifiers_synced_at.is_some() { + self.syncing = false; } } - - action } /// Handle backend task results for shielded operations. @@ -143,11 +113,16 @@ impl ShieldedTabView { self.initializing = false; self.is_initialized = true; self.shielded_balance = *balance; - // Auto-sync notes after initialization - self.syncing = true; - self.pending_task = Some(BackendTask::ShieldedTask(ShieldedTask::SyncNotes { - seed_hash: self.seed_hash, - })); + // Chain SyncNotes after user-initiated Resync (the only UI + // path that dispatches InitializeShieldedWallet). + if self.syncing || self.pending_task.is_some() { + // Already in a sync flow — skip duplicate chain. + } else { + self.syncing = true; + self.pending_task = Some(BackendTask::ShieldedTask(ShieldedTask::SyncNotes { + seed_hash: self.seed_hash, + })); + } true } BackendTaskSuccessResult::ShieldedNotesSynced { @@ -268,6 +243,9 @@ impl ShieldedTabView { } // --- Not yet initialized --- + // Initialization is handled by the backend (handle_wallet_unlocked). + // If the state is not yet available, the wallet is either locked or + // init is still running — show an appropriate message. if !self.is_initialized { if self.initializing { ui.horizontal(|ui| { @@ -275,79 +253,25 @@ impl ShieldedTabView { ui.label("Initializing shielded wallet (deriving ZIP32 keys)..."); }); } else { - ui.add_space(20.0); - ui.label( - RichText::new( - "Initialize your shielded wallet to enable private transactions.", - ) - .color(DashColors::text_secondary(dark_mode)), - ); - ui.add_space(10.0); - - let init_btn = egui::Button::new( - RichText::new("Initialize Shielded Wallet") - .color(Color32::WHITE) - .size(16.0), - ) - .fill(DashColors::DASH_BLUE); - - if ui.add(init_btn).clicked() { - // Get the wallet Arc - let wallet_arc = { - let wallets = self.app_context.wallets.read().unwrap(); - wallets.get(&self.seed_hash).cloned() - }; - - if let Some(wallet) = &wallet_arc { - if wallet_needs_unlock(wallet) { - // Wallet is locked — open unlock popup - self.wallet_unlock_popup.open(); - } else { - // Try open without password (for passwordless wallets) - let _ = try_open_wallet_no_password(wallet); - // Proceed to initialize - self.initializing = true; - action |= AppAction::BackendTask(BackendTask::ShieldedTask( - ShieldedTask::InitializeShieldedWallet { - seed_hash: self.seed_hash, - }, - )); - } - } - } - } - - // Show unlock popup if open - if self.wallet_unlock_popup.is_open() { - let wallet_arc = { + let wallet_locked = { let wallets = self.app_context.wallets.read().unwrap(); - wallets.get(&self.seed_hash).cloned() + wallets + .get(&self.seed_hash) + .is_some_and(wallet_needs_unlock) }; - - if let Some(wallet) = &wallet_arc { - let unlock_result = - self.wallet_unlock_popup - .show(ui.ctx(), wallet, &self.app_context); - match unlock_result { - WalletUnlockResult::Unlocked => { - // Wallet is now open — proceed to initialize - self.initializing = true; - action |= AppAction::BackendTask(BackendTask::ShieldedTask( - ShieldedTask::InitializeShieldedWallet { - seed_hash: self.seed_hash, - }, - )); - } - WalletUnlockResult::Cancelled => { - // User cancelled — do nothing - } - WalletUnlockResult::Pending => { - // Still showing popup - } - } + ui.add_space(20.0); + if wallet_locked { + ui.label( + RichText::new("Unlock the wallet to enable the shielded pool.") + .color(DashColors::text_secondary(dark_mode)), + ); + } else { + ui.horizontal(|ui| { + ui.add(egui::Spinner::new().color(DashColors::DASH_BLUE)); + ui.label("Preparing shielded wallet..."); + }); } } - return action; } diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index b247301f6..e61ef7a8f 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -222,8 +222,6 @@ impl WalletsBalancesScreen { .and_then(|hash| app_context.db.get_platform_sync_info(&hash).ok()) .filter(|(ts, _)| *ts > 0); - // Eagerly create the ShieldedTabView so the init/sync chain starts - // as soon as the wallet screen is constructed. let shielded_tab_view = selected_wallet .as_ref() .and_then(|w| w.read().ok().map(|g| g.seed_hash())) @@ -371,8 +369,6 @@ impl WalletsBalancesScreen { self.selected_account_tab = AccountTab::default(); self.cached_tx_indices = None; - // Eagerly create the ShieldedTabView so the init/sync chain starts - // immediately on wallet switch, not only when the user clicks the tab. self.shielded_tab_view = seed_hash.map(|hash| ShieldedTabView::new(&self.app_context, hash)); @@ -2193,8 +2189,8 @@ impl ScreenLike for WalletsBalancesScreen { AppAction::None }; - // Tick the shielded tab view every frame so the init/sync chain - // runs even when the Shielded tab is not the active tab. + // Tick the shielded tab view to drain any pending user-initiated + // tasks (e.g. Resync) even when the Shielded tab is not active. let shielded_tick_action = self .shielded_tab_view .as_mut() From a5e793024729594930f10628657c513f156a422b Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 19:39:49 +0100 Subject: [PATCH 091/147] fix(shielded): deduplicate notes in sync to prevent double balance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the commitment tree resets (empty/corrupt) but persisted notes remain in the DB, initialize_shielded_wallet loads notes from DB while last_synced_index falls to 0. The subsequent sync rescans from position 0 and re-appends all decrypted notes — duplicating the ones already in memory from DB, doubling the balance. Fix: sync_notes() now checks if a note at the same position already exists in shielded_state.notes before appending, preventing duplicates regardless of the last_synced_index value. Co-Authored-By: Claude Opus 4.6 --- src/backend_task/shielded/sync.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/backend_task/shielded/sync.rs b/src/backend_task/shielded/sync.rs index b63661d64..a1a2482dc 100644 --- a/src/backend_task/shielded/sync.rs +++ b/src/backend_task/shielded/sync.rs @@ -110,11 +110,20 @@ pub async fn sync_notes( } // Persist and record decrypted notes that are new (position >= already_have). + // Also skip notes already in memory (loaded from DB during init) to prevent + // double-counting when the commitment tree resets but persisted notes remain. let mut new_note_count = 0u32; for dn in result.decrypted_notes { if dn.position < already_have { continue; // already stored in a previous sync } + if shielded_state + .notes + .iter() + .any(|n| u64::from(n.position) == dn.position) + { + continue; // already loaded from DB during init + } // Compute the spending nullifier from our FVK (dn.nullifier is the rho/nf // field from the compact action, not the spending nullifier). From a4be13b667c6df81e94ab91ff92d834d3fa4491a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 19:48:08 +0100 Subject: [PATCH 092/147] feat(ui): add shielded diversified address table Replace the single-address display with prev/next buttons in the Shielded tab with a collapsible address table showing all generated diversified addresses. Each row shows the index, truncated bech32m address (click or Copy button to copy full address), and status (Default for index 0). The section is collapsed by default in normal mode and expanded in developer mode. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/shielded_tab.rs | 178 +++++++++++++++++++++------------ 1 file changed, 114 insertions(+), 64 deletions(-) diff --git a/src/ui/wallets/shielded_tab.rs b/src/ui/wallets/shielded_tab.rs index b228a74a1..0de1624e8 100644 --- a/src/ui/wallets/shielded_tab.rs +++ b/src/ui/wallets/shielded_tab.rs @@ -26,8 +26,6 @@ pub struct ShieldedTabView { tree_synced: bool, /// Pending backend task to dispatch on next ui() call (e.g., sync after Resync). pending_task: Option, - /// Currently selected diversified address index. - selected_address_index: u32, /// Number of diversified addresses generated (always >= 1). address_count: u32, } @@ -45,7 +43,6 @@ impl ShieldedTabView { is_initialized: false, tree_synced: false, pending_task: None, - selected_address_index: 0, address_count: 1, } } @@ -100,6 +97,110 @@ impl ShieldedTabView { } } + /// Render the collapsible shielded addresses section with a table of all + /// diversified addresses. + fn render_address_section(&mut self, ui: &mut Ui, dark_mode: bool) { + let dev_mode = self.app_context.is_developer_mode(); + + let header = egui::CollapsingHeader::new( + RichText::new("Shielded Addresses") + .size(16.0) + .color(DashColors::text_primary(dark_mode)), + ) + .id_salt("shielded_addresses") + .default_open(dev_mode); + + header.show(ui, |ui| { + ui.horizontal(|ui| { + if ui + .small_button("+") + .on_hover_text("Generate new diversified address") + .clicked() + { + self.address_count += 1; + } + }); + + ui.add_space(4.0); + + // Collect all addresses for the table + let addresses: Vec<(u32, String)> = { + let states = self.app_context.shielded_states.lock().unwrap(); + if let Some(state) = states.get(&self.seed_hash) { + (0..self.address_count) + .filter_map(|idx| { + use dash_sdk::dpp::address_funds::OrchardAddress; + use dash_sdk::grovedb_commitment_tree::Scope; + let addr = state.keys.fvk.address_at(idx, Scope::External); + let raw = addr.to_raw_address_bytes(); + let orchard_addr = OrchardAddress::from_raw_bytes(&raw).ok()?; + Some(( + idx, + orchard_addr.to_bech32m_string(self.app_context.network), + )) + }) + .collect() + } else { + vec![] + } + }; + + if addresses.is_empty() { + ui.label( + RichText::new("No addresses generated yet.") + .color(DashColors::text_secondary(dark_mode)), + ); + return; + } + + egui::Grid::new("shielded_addresses_grid") + .num_columns(4) + .striped(true) + .spacing([20.0, 4.0]) + .show(ui, |ui| { + ui.label(RichText::new("Index").strong()); + ui.label(RichText::new("Address").strong()); + ui.label(RichText::new("Status").strong()); + ui.label(""); // Copy column header + ui.end_row(); + + for (idx, full_addr) in &addresses { + // Index column + if *idx == 0 { + ui.label("0 (Default)"); + } else { + ui.label(idx.to_string()); + } + + // Address column: truncated, clickable to copy + let truncated = truncate_address(full_addr); + let addr_response = ui.add( + egui::Label::new(RichText::new(&truncated).monospace()) + .sense(egui::Sense::click()), + ); + if addr_response.clicked() { + let _ = copy_text_to_clipboard(full_addr); + } + addr_response.on_hover_text(full_addr.as_str()); + + // Status column + if *idx == 0 { + ui.label("Default"); + } else { + ui.label(""); + } + + // Copy button column + if ui.small_button("Copy").clicked() { + let _ = copy_text_to_clipboard(full_addr); + } + + ui.end_row(); + } + }); + }); + } + /// Handle backend task results for shielded operations. pub fn handle_result( &mut self, @@ -302,67 +403,8 @@ impl ShieldedTabView { ui.add_space(10.0); - // Payment address (bech32m encoded: dash1z... or tdash1z...) - let address_str = { - let states = self.app_context.shielded_states.lock().unwrap(); - states.get(&self.seed_hash).and_then(|state| { - use dash_sdk::dpp::address_funds::OrchardAddress; - use dash_sdk::grovedb_commitment_tree::Scope; - let addr = state - .keys - .fvk - .address_at(self.selected_address_index, Scope::External); - let raw = addr.to_raw_address_bytes(); - let orchard_addr = OrchardAddress::from_raw_bytes(&raw).ok()?; - Some(orchard_addr.to_bech32m_string(self.app_context.network)) - }) - }; - - if let Some(addr) = &address_str { - Frame::new() - .fill(DashColors::surface(dark_mode)) - .inner_margin(Margin::symmetric(16, 12)) - .corner_radius(8.0) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new(format!( - "Shielded Payment Address ({})", - self.selected_address_index - )) - .size(14.0) - .color(DashColors::text_secondary(dark_mode)), - ); - - // Address selector: prev/next arrows - if self.selected_address_index > 0 && ui.small_button("<").clicked() { - self.selected_address_index -= 1; - } - if self.selected_address_index + 1 < self.address_count - && ui.small_button(">").clicked() - { - self.selected_address_index += 1; - } - - // Generate new diversified address - if ui - .small_button("+") - .on_hover_text("Generate new diversified address") - .clicked() - { - self.selected_address_index = self.address_count; - self.address_count += 1; - } - }); - ui.add_space(4.0); - ui.horizontal(|ui| { - ui.monospace(addr); - if ui.small_button("Copy").clicked() { - let _ = copy_text_to_clipboard(addr); - } - }); - }); - } + // Shielded Addresses (collapsible table) + self.render_address_section(ui, dark_mode); ui.add_space(10.0); @@ -638,6 +680,14 @@ impl ShieldedTabView { } } +/// Truncate a bech32m address for display: first 12 chars + `...` + last 8 chars. +fn truncate_address(addr: &str) -> String { + if addr.len() <= 23 { + return addr.to_string(); + } + format!("{}...{}", &addr[..12], &addr[addr.len() - 8..]) +} + fn format_credits(credits: u64) -> String { let dash = credits as f64 / CREDITS_PER_DUFF as f64 / 1e8; if dash >= 0.01 { From 758171354849f4caaa12695f8eee1c2f8e8decf7 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 25 Mar 2026 08:11:21 +0100 Subject: [PATCH 093/147] =?UTF-8?q?fix:=20address=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20duplicate=20controls,=20perf,=20tab=20visibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove duplicate sync status/buttons block in shielded_tab.rs - Fix BIP44 accounts with index > 0 being invisible (regression) - Fix early return in render_account_tabs blocking Shielded tab for new wallets - Fix double-tick of ShieldedTabView per frame when tab is active - Fix system_tab_sections O(n*m) perf by precomputing address counts - Track queue_shielded_sync via subtasks for graceful shutdown - Use HashSet for O(1) duplicate note check in sync (was O(n^2)) - Fix balance_breakdown default_open to respect developer mode - Fix is_system_category to delegate to is_visible_in_default_mode - Fix doc comment for system_tab_sections sort order claim - Narrow #[allow(dead_code)] to specific field on AccountSummary Co-Authored-By: Claude Opus 4.6 --- src/backend_task/shielded/sync.rs | 12 ++-- src/context/wallet_lifecycle.rs | 50 +++++++++------ src/ui/wallets/account_summary.rs | 7 +-- src/ui/wallets/shielded_tab.rs | 60 ------------------ src/ui/wallets/wallets_screen/mod.rs | 92 ++++++++++++++++------------ 5 files changed, 91 insertions(+), 130 deletions(-) diff --git a/src/backend_task/shielded/sync.rs b/src/backend_task/shielded/sync.rs index a1a2482dc..d0978f220 100644 --- a/src/backend_task/shielded/sync.rs +++ b/src/backend_task/shielded/sync.rs @@ -112,16 +112,18 @@ pub async fn sync_notes( // Persist and record decrypted notes that are new (position >= already_have). // Also skip notes already in memory (loaded from DB during init) to prevent // double-counting when the commitment tree resets but persisted notes remain. + // Build a HashSet of existing positions for O(1) lookups instead of O(n) scans. + let existing_positions: std::collections::HashSet = shielded_state + .notes + .iter() + .map(|n| u64::from(n.position)) + .collect(); let mut new_note_count = 0u32; for dn in result.decrypted_notes { if dn.position < already_have { continue; // already stored in a previous sync } - if shielded_state - .notes - .iter() - .any(|n| u64::from(n.position) == dn.position) - { + if existing_positions.contains(&dn.position) { continue; // already loaded from DB during init } diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index 6ea6478a5..30c26cd08 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -186,30 +186,40 @@ impl AppContext { } /// Queue async SyncNotes -> CheckNullifiers for an already-initialized - /// shielded wallet. Uses `spawn_blocking` + `block_on` to sidestep - /// rust-lang/rust#100013 (`self: &Arc` futures are not Send). + /// shielded wallet. Tracked via `subtasks` so it participates in graceful + /// shutdown and cancellation. fn queue_shielded_sync(self: &Arc, seed_hash: WalletSeedHash) { let ctx = Arc::clone(self); - let handle = tokio::runtime::Handle::current(); - tokio::task::spawn_blocking(move || { - handle.block_on(async { - match ctx.sync_shielded_notes(seed_hash).await { - Ok(_) => { - if let Err(e) = ctx.check_nullifiers_task(seed_hash).await { - tracing::debug!( - seed = %hex::encode(seed_hash), - error = %e, - "Shielded nullifier check after init failed" - ); + self.subtasks.spawn_sync("shielded_sync", async move { + let handle = tokio::runtime::Handle::current(); + let result = tokio::task::spawn_blocking(move || { + handle.block_on(async { + match ctx.sync_shielded_notes(seed_hash).await { + Ok(_) => { + if let Err(e) = ctx.check_nullifiers_task(seed_hash).await { + tracing::debug!( + seed = %hex::encode(seed_hash), + error = %e, + "Shielded nullifier check after init failed" + ); + } } + Err(e) => tracing::debug!( + seed = %hex::encode(seed_hash), + error = %e, + "Shielded note sync after init failed" + ), } - Err(e) => tracing::debug!( - seed = %hex::encode(seed_hash), - error = %e, - "Shielded note sync after init failed" - ), - } - }); + }) + }) + .await; + if let Err(e) = result { + tracing::debug!( + seed = %hex::encode(seed_hash), + error = %e, + "Shielded sync task panicked" + ); + } }); } diff --git a/src/ui/wallets/account_summary.rs b/src/ui/wallets/account_summary.rs index 376946335..10baf6967 100644 --- a/src/ui/wallets/account_summary.rs +++ b/src/ui/wallets/account_summary.rs @@ -186,10 +186,7 @@ impl AccountCategory { /// Returns true if this is a "system" account category shown only in /// developer mode under the consolidated System tab. pub fn is_system_category(&self) -> bool { - !matches!( - self, - AccountCategory::Bip44 | AccountCategory::PlatformPayment - ) + !self.is_visible_in_default_mode() } } @@ -217,9 +214,9 @@ pub(crate) fn categorize_account_path( } #[derive(Clone, Debug)] -#[allow(dead_code)] pub struct AccountSummary { pub category: AccountCategory, + #[allow(dead_code)] pub label: String, pub index: Option, pub confirmed_balance: u64, diff --git a/src/ui/wallets/shielded_tab.rs b/src/ui/wallets/shielded_tab.rs index 0de1624e8..61b246902 100644 --- a/src/ui/wallets/shielded_tab.rs +++ b/src/ui/wallets/shielded_tab.rs @@ -614,66 +614,6 @@ impl ShieldedTabView { .color(DashColors::text_secondary(dark_mode)), ); } - - // Sync status indicator - if self.syncing { - ui.add(egui::Spinner::new().color(DashColors::DASH_BLUE)); - ui.label( - RichText::new("Syncing...") - .size(12.0) - .color(DashColors::DASH_BLUE), - ); - } else if self.tree_synced { - ui.label( - RichText::new("Synced") - .size(12.0) - .color(Color32::DARK_GREEN), - ); - } - - // Sync buttons - if !self.syncing { - if ui.small_button("Sync Notes").clicked() { - self.syncing = true; - self.success_message = None; - self.error_message = None; - action |= AppAction::BackendTask(BackendTask::ShieldedTask( - ShieldedTask::SyncNotes { - seed_hash: self.seed_hash, - }, - )); - } - - if self.app_context.is_developer_mode() && ui.small_button("Resync Notes").clicked() - { - // Remove in-memory state entirely (will be recreated by init) - { - let mut states = self.app_context.shielded_states.lock().unwrap(); - states.remove(&self.seed_hash); - } - // Clear persisted notes and commitment tree data - let network_str = self.app_context.network.to_string(); - let _ = self - .app_context - .db - .delete_shielded_notes(&self.seed_hash, &network_str); - let _ = self.app_context.db.clear_commitment_tree_tables(); - - self.shielded_balance = 0; - self.tree_synced = false; - self.is_initialized = false; - self.initializing = true; - self.syncing = false; - self.success_message = None; - self.error_message = None; - // Re-initialize (creates fresh persistent tree) then auto-syncs - action |= AppAction::BackendTask(BackendTask::ShieldedTask( - ShieldedTask::InitializeShieldedWallet { - seed_hash: self.seed_hash, - }, - )); - } - } }); action diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index e61ef7a8f..f1b7b8990 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -1145,11 +1145,9 @@ impl WalletsBalancesScreen { let developer_mode = self.app_context.is_developer_mode(); let mut tabs: Vec = Vec::new(); - // Always-visible primary tabs: Dash Core (Bip44 index 0) and Platform + // Always-visible primary tabs: all BIP44 accounts and Platform for summary in summaries { - let visible = summary.category.is_visible_in_default_mode() && summary.index == Some(0) - || summary.category == AccountCategory::PlatformPayment; - if !visible { + if !summary.category.is_visible_in_default_mode() { continue; } tabs.push(AccountTab::Category( @@ -1178,8 +1176,8 @@ impl WalletsBalancesScreen { } /// Collect the system account categories to display inside the System tab. - /// Returns `(category, index, address_count, balance_duffs)` tuples sorted - /// by the category's natural sort order. + /// Returns `(category, index, address_count, balance_duffs)` tuples in a + /// fixed display order (identity categories first, then provider, then legacy). fn system_tab_sections( &self, summaries: &[AccountSummary], @@ -1197,10 +1195,15 @@ impl WalletsBalancesScreen { AccountCategory::Bip32, ]; + // Precompute per-category address counts in a single pass over + // watched_addresses to avoid O(num_categories * num_addresses) + // per frame. + let address_counts = self.precompute_address_counts(); + let mut sections = Vec::new(); for cat in &all_system_categories { let matching: Vec<_> = summaries.iter().filter(|s| &s.category == cat).collect(); - let address_count = self.count_addresses_for_category(cat); + let address_count = address_counts.get(cat).copied().unwrap_or(0); let balance: u64 = matching.iter().map(|s| s.confirmed_balance).sum(); let idx = matching.first().and_then(|s| s.index); sections.push((cat.clone(), idx, address_count, balance)); @@ -1211,7 +1214,7 @@ impl WalletsBalancesScreen { if matches!(summary.category, AccountCategory::Other(_)) && !sections.iter().any(|(c, _, _, _)| *c == summary.category) { - let address_count = self.count_addresses_for_category(&summary.category); + let address_count = address_counts.get(&summary.category).copied().unwrap_or(0); sections.push(( summary.category.clone(), summary.index, @@ -1224,27 +1227,27 @@ impl WalletsBalancesScreen { sections } - /// Count addresses belonging to a given category in the selected wallet. - fn count_addresses_for_category(&self, category: &AccountCategory) -> usize { + /// Build a per-category address count map in a single pass over + /// `watched_addresses`. Used by `system_tab_sections` to avoid + /// O(num_categories * num_addresses) per frame. + fn precompute_address_counts(&self) -> std::collections::HashMap { + let mut counts = std::collections::HashMap::new(); let Some(wallet_arc) = self.selected_wallet.as_ref() else { - return 0; + return counts; }; let Ok(wallet) = wallet_arc.read() else { - return 0; + return counts; }; let network = self.app_context.network; - wallet - .watched_addresses - .iter() - .filter(|(path, _info)| { - let (cat, _) = crate::ui::wallets::account_summary::categorize_account_path( - path, - network, - _info.path_reference, - ); - &cat == category - }) - .count() + for (path, info) in &wallet.watched_addresses { + let (cat, _) = crate::ui::wallets::account_summary::categorize_account_path( + path, + network, + info.path_reference, + ); + *counts.entry(cat).or_insert(0) += 1; + } + counts } /// Format a duffs balance for tab labels: max 4 decimal places, trimmed. @@ -1263,16 +1266,6 @@ impl WalletsBalancesScreen { ui.add_space(14.0); - if summaries.is_empty() - && !matches!( - self.selected_account_tab, - AccountTab::Shielded | AccountTab::System - ) - { - ui.label("No account activity yet."); - return action; - } - let tabs = self.build_account_tabs(summaries); // Ensure the selected tab is still valid @@ -1370,6 +1363,19 @@ impl WalletsBalancesScreen { action |= self.render_system_tab_content(ui, summaries); } AccountTab::Category(cat, idx) => { + // Show empty state if no summaries match this category + if !summaries + .iter() + .any(|s| s.category == *cat && s.index == *idx) + && !matches!(cat, AccountCategory::Bip44) + { + ui.label( + RichText::new("No account activity yet.") + .color(DashColors::text_secondary(dark_mode)), + ); + return action; + } + // Show description for the selected account category if let Some(description) = cat.description() { ui.label( @@ -1929,7 +1935,7 @@ impl WalletsBalancesScreen { .color(DashColors::text_secondary(dark_mode)), ) .id_salt("balance_breakdown") - .default_open(false); + .default_open(self.app_context.is_developer_mode()); header.show(ui, |ui| { ui.horizontal(|ui| { @@ -2191,11 +2197,17 @@ impl ScreenLike for WalletsBalancesScreen { // Tick the shielded tab view to drain any pending user-initiated // tasks (e.g. Resync) even when the Shielded tab is not active. - let shielded_tick_action = self - .shielded_tab_view - .as_mut() - .map(|v| v.tick()) - .unwrap_or(AppAction::None); + // Skip when the Shielded tab IS active — its ui() method already + // calls tick(), and double-ticking would acquire the lock twice + // per frame for no benefit. + let shielded_tick_action = if self.selected_account_tab != AccountTab::Shielded { + self.shielded_tab_view + .as_mut() + .map(|v| v.tick()) + .unwrap_or(AppAction::None) + } else { + AppAction::None + }; let mut right_buttons = vec![ ( From 8e2eb9c25682596b2e9eec223d483aa6b9e732e9 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 25 Mar 2026 08:20:35 +0100 Subject: [PATCH 094/147] fix(ui): replace mutex unwrap() with graceful error handling in shielded tab Replace .lock().unwrap() calls on shielded_states with .lock().ok() patterns throughout ShieldedTabView to prevent UI panics on poisoned mutex. Shows fallback messages or skips updates gracefully. Co-Authored-By: Claude Opus 4.6 --- src/ui/wallets/shielded_tab.rs | 40 ++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/src/ui/wallets/shielded_tab.rs b/src/ui/wallets/shielded_tab.rs index 61b246902..2d3fcf069 100644 --- a/src/ui/wallets/shielded_tab.rs +++ b/src/ui/wallets/shielded_tab.rs @@ -125,7 +125,13 @@ impl ShieldedTabView { // Collect all addresses for the table let addresses: Vec<(u32, String)> = { - let states = self.app_context.shielded_states.lock().unwrap(); + let Ok(states) = self.app_context.shielded_states.lock() else { + ui.label( + RichText::new("Unable to read shielded state.") + .color(DashColors::text_secondary(dark_mode)), + ); + return; + }; if let Some(state) = states.get(&self.seed_hash) { (0..self.address_count) .filter_map(|idx| { @@ -278,11 +284,11 @@ impl ShieldedTabView { } if *seed_hash == self.seed_hash => { self.syncing = false; // Update balance from state after nullifier check - let states = self.app_context.shielded_states.lock().unwrap(); - if let Some(state) = states.get(&self.seed_hash) { + if let Ok(states) = self.app_context.shielded_states.lock() + && let Some(state) = states.get(&self.seed_hash) + { self.shielded_balance = state.shielded_balance; } - drop(states); if *spent_count > 0 { self.success_message = Some(format!("Detected {} spent note(s)", spent_count)); } @@ -486,16 +492,19 @@ impl ShieldedTabView { // Notes section header with sync status and buttons let (notes_info, synced_index): (Vec<(u64, u64, bool)>, u64) = { - let states = self.app_context.shielded_states.lock().unwrap(); - states - .get(&self.seed_hash) - .map(|state| { - let notes = state - .notes - .iter() - .map(|n| (n.value, n.block_height, n.is_spent)) - .collect(); - (notes, state.last_synced_index) + self.app_context + .shielded_states + .lock() + .ok() + .and_then(|states| { + states.get(&self.seed_hash).map(|state| { + let notes = state + .notes + .iter() + .map(|n| (n.value, n.block_height, n.is_spent)) + .collect(); + (notes, state.last_synced_index) + }) }) .unwrap_or_default() }; @@ -551,8 +560,7 @@ impl ShieldedTabView { if self.app_context.is_developer_mode() && ui.small_button("Resync Notes").clicked() { - { - let mut states = self.app_context.shielded_states.lock().unwrap(); + if let Ok(mut states) = self.app_context.shielded_states.lock() { states.remove(&self.seed_hash); } let network_str = self.app_context.network.to_string(); From f2e8142fda3f61546f57293f6cd0e5d7d20be847 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 09:52:35 +0100 Subject: [PATCH 095/147] fix(mcp): resolve async lifetime errors for Rust 2024 edition Use spawn_blocking + block_on in dispatch_task to avoid Send bound issues with platform SDK types (DataContract/Sdk references across await points). Same pattern already used by AppState::handle_backend_task. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/mcp/dispatch.rs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/mcp/dispatch.rs b/src/mcp/dispatch.rs index f52895dab..9d0a289f9 100644 --- a/src/mcp/dispatch.rs +++ b/src/mcp/dispatch.rs @@ -12,12 +12,22 @@ use std::sync::Arc; /// for the GUI event loop. In MCP/CLI mode there is no GUI consuming the /// receiver, so it is intentionally dropped. The task result is returned /// directly from the async call rather than through the channel. -/// Same pattern as `tests/backend-e2e/framework/task_runner.rs`. +/// +/// Uses `spawn_blocking` + `block_on` to avoid `Send` bound issues with +/// platform SDK types (`DataContract`/`Sdk` references across await points). +/// Same pattern as `AppState::handle_backend_task` in `app.rs`. pub(crate) async fn dispatch_task( app_context: &Arc, task: BackendTask, ) -> Result { - let (tx, _rx) = tokio::sync::mpsc::channel::(32); - let sender = crate::utils::egui_mpsc::SenderAsync::new(tx, egui::Context::default()); - app_context.run_backend_task(task, sender).await + let app_context = app_context.clone(); + let handle = tokio::runtime::Handle::current(); + tokio::task::spawn_blocking(move || { + handle.block_on(async move { + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = crate::utils::egui_mpsc::SenderAsync::new(tx, egui::Context::default()); + app_context.run_backend_task(task, sender).await + }) + }) + .await? } From 46ea490d5ba68938b8977f4e40981cff4775f6de Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 10:18:39 +0100 Subject: [PATCH 096/147] chore: fix mcp dispatch --- src/mcp/dispatch.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp/dispatch.rs b/src/mcp/dispatch.rs index 9d0a289f9..deb435cbb 100644 --- a/src/mcp/dispatch.rs +++ b/src/mcp/dispatch.rs @@ -24,7 +24,7 @@ pub(crate) async fn dispatch_task( let handle = tokio::runtime::Handle::current(); tokio::task::spawn_blocking(move || { handle.block_on(async move { - let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let (tx, _) = tokio::sync::mpsc::channel::(32); let sender = crate::utils::egui_mpsc::SenderAsync::new(tx, egui::Context::default()); app_context.run_backend_task(task, sender).await }) From cfa1dbbae64ecae007574e007aa722a4bfa94382 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 10:20:14 +0100 Subject: [PATCH 097/147] doc: remove obsolete manual teting docs --- .../manual-tests.md | 254 ------------------ .../manual-test-scenarios.md | 105 -------- .../manual-test-scenarios.md | 61 ----- .../manual-test.md | 24 -- 4 files changed, 444 deletions(-) delete mode 100644 docs/ai-design/2025-02-25-spv-peer-rework/manual-tests.md delete mode 100644 docs/ai-design/2026-02-24-spv-sync-error-status/manual-test-scenarios.md delete mode 100644 docs/ai-design/2026-03-03-fix-nonce-reset-on-refresh/manual-test-scenarios.md delete mode 100644 docs/ai-design/2026-03-05-banner-details-overlap/manual-test.md diff --git a/docs/ai-design/2025-02-25-spv-peer-rework/manual-tests.md b/docs/ai-design/2025-02-25-spv-peer-rework/manual-tests.md deleted file mode 100644 index 41aeecaa5..000000000 --- a/docs/ai-design/2025-02-25-spv-peer-rework/manual-tests.md +++ /dev/null @@ -1,254 +0,0 @@ -# Manual Test Scenarios: SPV Peer Status Rework - -**Feature:** Replace SPV auto-stop with Connecting state and degraded-state warning -**Branch:** `fix/spv-peer-timeout` -**Date:** 2025-02-25 - -## Overview - -These scenarios verify the reworked SPV peer connection lifecycle: - -- SPV **no longer auto-stops** on peer timeout -- it keeps running and retrying peer discovery. -- New **four-state** connection indicator: Red (Disconnected), Orange/fast-pulse (Connecting), Orange/slow-pulse (Syncing), Green (Synced). -- After ~30 seconds with zero peers, a **degraded warning** appears in the tooltip ("Having trouble finding peers...") but SPV stays running. - ---- - -## Global Preconditions - -1. Dash Evo Tool is installed and launches successfully. -2. The application is configured for **SPV mode** unless stated otherwise. -3. At least one network (Testnet or Mainnet) is configured with valid DAPI endpoints. -4. Tester has access to network controls (firewall rules, VPN toggle) to simulate peer availability. - ---- - -## Scenario 1: Fresh SPV Start on Good Network - -**Goal:** Verify the Connecting -> Syncing -> Synced progression. - -### Steps - -1. Launch the application with normal network connectivity. -2. Observe the connection indicator immediately after SPV starts. -3. Hover over the indicator to read the tooltip. -4. Wait for peers to connect (usually within a few seconds). -5. Observe the indicator transition during sync phases. -6. Wait for sync to complete. - -### Expected Results - -- **Step 2:** Orange circle with **fast pulse** (`time * 2.5`). Tooltip header: "Connecting...". -- **Step 4:** Once peers connect, indicator remains orange but pulse slows (`time * 1.2`). Tooltip header changes to "Syncing". SPV label shows phase progress (e.g., "SPV: Headers: 12345 / 27000 (45%)"). -- **Step 6:** Indicator turns **green** with normal pulse. Tooltip: "Ready\nSPV: Synced\nDAPI: Available (N unbanned / M total endpoints)". - ---- - -## Scenario 2: Fresh SPV Start on Bad Network (No Peers) - -**Goal:** Verify SPV stays running and shows degraded warning instead of stopping. - -### Steps - -1. Block outbound P2P peer connections (firewall on port 9999/19999). -2. Launch the application. Ensure DAPI endpoints are reachable. -3. Observe the indicator -- should show orange (Connecting). -4. Wait approximately 30 seconds. -5. Hover over the indicator to read the tooltip. -6. Continue waiting 1-2 minutes. - -### Expected Results - -- **Step 3:** Orange indicator with fast pulse. Tooltip: "Connecting...\nSPV: Starting\nDAPI: Available (...)". -- **Step 5:** After ~30s, tooltip adds: "\nHaving trouble finding peers. Check your connection." A **warning banner** also appears with the same text. -- **Step 6:** SPV **remains running** (does NOT stop). Indicator stays orange. SPV continues trying DNS lookups and peer discovery in the background. -- **Critical:** Verify NO "SPV disconnected" error banner. Verify `stop_spv()` is NOT called (check logs -- no "stopping SPV" message). -- **Recovery:** If you restore connectivity and peers connect, the warning banner **automatically clears** and the indicator transitions to Syncing/Synced. - ---- - -## Scenario 3: Peers Disconnect Mid-Sync - -**Goal:** Verify state transitions back to Connecting (not Disconnected) when peers drop. - -### Steps - -1. Start SPV and wait for it to begin syncing with peers (orange, slow pulse). -2. Block all peer connections via firewall. -3. Observe the indicator over the next 5-10 seconds. - -### Expected Results - -- **Step 2:** Peer count drops to 0. The `spv_no_peers_since` timer starts. -- **Step 3:** Indicator changes from slow-pulse orange (Syncing) to **fast-pulse orange (Connecting)**. SPV status remains `Syncing` but with 0 peers, `refresh_state()` maps this to `Connecting`. -- SPV does NOT stop. No error banner. -- After 30s of no peers, tooltip adds "Having trouble finding peers...". - ---- - -## Scenario 4: Peers Reconnect After Disconnect - -**Goal:** Verify seamless recovery when peers become available again. - -### Steps - -1. Complete Scenario 3 (SPV is in Connecting state, no peers). -2. Restore network connectivity (remove firewall rule). -3. Observe the indicator. - -### Expected Results - -- **Step 2-3:** Peers reconnect via SPV's internal discovery. `spv_no_peers_since` is cleared (`peers > 0` resets to `None`). -- Indicator transitions from fast-pulse orange (Connecting) to slow-pulse orange (Syncing) as peers connect and sync resumes. -- Eventually reaches green (Synced) once sync completes. -- No manual restart needed -- SPV recovered on its own. - ---- - -## Scenario 5: Network Switch - -**Goal:** Verify connection state resets cleanly on network switch. - -### Steps - -1. Confirm SPV is synced on current network (green indicator). -2. Switch to a different network via the network chooser. -3. Observe the indicator immediately after switch. -4. Wait for SPV to start on the new network. - -### Expected Results - -- **Step 2:** `ConnectionStatus::reset()` clears all state: `spv_status` -> Idle, `spv_connected_peers` -> 0, `spv_no_peers_since` -> None, `overall_state` -> Disconnected. -- **Step 3:** Indicator turns **red** momentarily. -- **Step 4:** Transitions to orange/Connecting, then Syncing, then green/Synced. - ---- - -## Scenario 6: All DAPI Endpoints Banned - -**Goal:** Verify that losing DAPI forces Disconnected even with SPV peers. - -### Steps - -1. Confirm SPV is synced (green indicator). -2. Cause all DAPI endpoints to become banned. -3. Observe the indicator after the next refresh cycle (within 1-4 seconds). - -### Expected Results - -- `refresh_state()` returns `Disconnected` because `dapi_available()` is false. -- Indicator turns **red**, regardless of SPV peer status. -- Tooltip: "Disconnected\nSPV: Synced\nDAPI: All M endpoints banned". - ---- - -## Scenario 7: Connection Indicator Visual States - -**Goal:** Verify all four visual states render correctly. - -### Expected Results - -| State | Color | Pulse | Background glow | -|---|---|---|---| -| Disconnected | Red (`error_color`) | None (`scale = 1.0`) | Same radius as main circle | -| Connecting | Orange (`warning_color`) | Fast pulse (`1.0 + 0.2 * sin(t*2.5)`) | Pulsating with 0.3 opacity | -| Syncing | Orange (`warning_color`) | Slow pulse (`1.0 + 0.15 * sin(t*1.2)`) | Pulsating with 0.3 opacity | -| Synced | Green (`success_color`) | Normal pulse (`1.0 + 0.2 * sin(t*2.0)`) | Pulsating with 0.3 opacity | - -- Connecting and Syncing use the same orange color but differ in pulse rate. -- Only Disconnected does NOT call `repaint_animation`. - ---- - -## Scenario 8: Tooltip Text for Each State - -**Goal:** Verify tooltip accuracy across all SPV states. - -| SPV Status | Peers | Overall State | Tooltip line 1 | Tooltip line 2 | Extra | -|---|---|---|---|---|---| -| Idle | 0 | Disconnected | "Disconnected" | "SPV: Idle" | -- | -| Starting | 0 | Connecting | "Connecting..." | "SPV: Starting" | After 30s: "Having trouble finding peers..." | -| Syncing | >0 | Syncing | "Syncing" | "SPV: Headers: X / Y (Z%)" | Phase progress shown | -| Syncing | 0 | Connecting | "Connecting..." | "SPV: " | After 30s: degraded warning | -| Running | >0 | Synced | "Ready" | "SPV: Synced" | -- | -| Running | 0 | Connecting | "Connecting..." | "SPV: Synced" | After 30s: degraded warning | -| Stopping | 0 | Connecting | "Connecting..." | "SPV: Stopping" | -- | -| Stopped | 0 | Disconnected | "Disconnected" | "SPV: Stopped" | -- | - ---- - -## Scenario 9: Running State with Peers Dropping to Zero - -**Goal:** Verify Running (Synced) transitions correctly when peers vanish. - -### Steps - -1. Confirm green indicator (SPV Running, peers connected). -2. Block peer connections. -3. Observe the indicator over 30+ seconds. - -### Expected Results - -- Once peer count drops to 0, `refresh_state()` maps active SPV with zero peers to `Connecting` (fast orange pulse). -- **Wait** -- `Running` status means sync finished. The SPV library may transition to a different status internally if peers drop. Observe actual SpvStatus transitions. -- If SPV stays `Running` with 0 peers: indicator should remain **orange** (`Connecting`), and the `spv_no_peers_since` timer continues without calling `stop_spv()`. -- After 30s with 0 peers, degraded warning banner and tooltip appear. - ---- - -## Scenario 10: Long-Running Stability - -**Goal:** Verify no resource leaks from peer tracking. - -### Steps - -1. Launch and sync SPV fully. -2. Note memory usage (RSS). -3. Run for 1+ hour with occasional peer churn (toggle connectivity 2-3 times). -4. Check memory every 15 minutes. - -### Expected Results - -- Memory stable (no unbounded growth). -- `spv_no_peers_since` is `Option` (fixed size), `spv_connected_peers` is `AtomicU16`. -- After peer churn, returns to Synced without stale state. -- No Mutex poisoning or deadlock warnings in logs. - ---- - -## Scenario 11: RPC Mode Unaffected - -**Goal:** Verify SPV peer logic doesn't interfere with RPC mode. - -### Steps - -1. Launch in RPC mode (Dash Core wallet connected). -2. Verify indicator follows RPC/ZMQ status. -3. Stop Dash Core. -4. Observe indicator. - -### Expected Results - -- No SPV timeout logic is involved. -- Green when RPC online + ZMQ connected + DAPI available; red otherwise. -- Tooltip shows RPC-specific content. -- No "Having trouble finding peers..." or SPV-related messages. - ---- - -## Scenario 12: Connecting State vs Syncing Pulse Differentiation - -**Goal:** Verify the visual difference between Connecting and Syncing is noticeable. - -### Steps - -1. Start SPV with peers blocked -- observe Connecting state pulse. -2. Unblock peers -- observe transition to Syncing state pulse. -3. Compare the two pulse rates side by side (or record video). - -### Expected Results - -- **Connecting** pulse is noticeably faster (2.5 Hz base) with slightly larger amplitude. -- **Syncing** pulse is calmer (1.2 Hz base) with smaller amplitude. -- Both are orange -- the pulse rate is the primary visual differentiator. -- Transition between states should be smooth (no flicker or jump). diff --git a/docs/ai-design/2026-02-24-spv-sync-error-status/manual-test-scenarios.md b/docs/ai-design/2026-02-24-spv-sync-error-status/manual-test-scenarios.md deleted file mode 100644 index c61838808..000000000 --- a/docs/ai-design/2026-02-24-spv-sync-error-status/manual-test-scenarios.md +++ /dev/null @@ -1,105 +0,0 @@ -# Manual Test Scenarios: SPV Sync Error Status - -## Context - -When SPV sync encounters a fatal error (e.g., masternode sync failure), the app -should transition from "Syncing" to a distinct Error state, with the connectivity -icon turning **magenta** (with "!" glyph and slow pulse) and the tooltip showing -the specific error message. - -## Prerequisites - -- Dash Evo Tool built with the fix applied -- Access to Testnet (or a network where SPV sync can be triggered) -- SPV backend mode enabled (not RPC mode) - -## Scenario 1: Verify error state on sync failure - -**Goal:** Confirm the connectivity icon transitions to Error (magenta) when -SPV sync fails, distinct from Disconnected (red). - -### Steps - -1. Launch Dash Evo Tool and connect to Testnet in SPV mode. -2. Observe the top-left connectivity icon during sync — it should pulse orange - (Syncing state). -3. If sync completes successfully, the icon should turn green (Running state). -4. If sync fails (e.g., masternode QRInfo failure visible in logs), observe: - - The connectivity icon turns **magenta** with a slow pulsation and a white - **"!"** glyph in the center. - - Hovering over the icon shows tooltip: **"SPV sync error: {detail}"** with - the specific error message (e.g., "Sync manager Masternode failed: ..."). - - Below that: **"SPV: Error"** detail line. -5. Open the Network Chooser screen and check the SPV status detail — it should - display the error message. - -### Expected Result - -- Icon transitions from orange (Syncing) to magenta (Error) on sync failure. -- Error icon is visually distinct from red (Disconnected) — magenta color, - slow pulse, "!" glyph. -- Tooltip shows "SPV sync error: ..." with the specific error message. -- Error message is visible in the status detail panel. - -## Scenario 2: Verify normal sync still works - -**Goal:** Confirm the fix doesn't break the happy path. - -### Steps - -1. Launch Dash Evo Tool and connect to Testnet in SPV mode. -2. Wait for sync to complete (may take several minutes on first sync). -3. Observe the connectivity icon transitions: - - Orange (Syncing) during sync. - - Green (Running) after sync completes. -4. Hover over the icon — tooltip should show "SPV synced" with "SPV: Running". - -### Expected Result - -- Sync completes normally, icon turns green. -- No false error transitions during normal sync. - -## Scenario 3: Verify error message content - -**Goal:** Confirm the error message stored in `last_error` contains useful -diagnostic information. - -### Steps - -1. Trigger an SPV sync that fails (e.g., by connecting to a network with - known chain lock propagation issues). -2. Check application logs for the error: - - Look for `SPV manager ... reported error: ...` log line. -3. Hover over the connectivity icon and verify the tooltip shows the same - error message (not a generic "Sync failed" without context). - -### Expected Result - -- Log contains `SPV manager "Masternode" reported error: Masternode sync failed: ...`. -- Tooltip shows the specific error from the sync manager, including - the block hash reference. - -## Scenario 4: Verify Error state is distinct from Disconnected - -**Goal:** Confirm the user can visually distinguish Error from Disconnected. - -### Steps - -1. With the app in SPV mode, trigger a sync error (Scenario 1). -2. Note the icon appearance: magenta, pulsating, "!" glyph. -3. Switch to a network with no connectivity (e.g., disconnect network). -4. Note the icon appearance: red, static, no glyph. - -### Expected Result - -- Error state: magenta circle, slow pulse, white "!" glyph. -- Disconnected state: red circle, static (no pulse), no glyph. -- The two states are clearly visually distinguishable. - -## Notes - -- The actual QRInfo chain lock error is an upstream issue - (dashpay/rust-dashcore#470). This fix ensures the app **reports** the error - correctly rather than silently staying stuck in "Syncing". -- A separate upstream issue (dashpay/rust-dashcore#469) tracks the missing - `try_emit_progress()` call on error paths in dash-spv. diff --git a/docs/ai-design/2026-03-03-fix-nonce-reset-on-refresh/manual-test-scenarios.md b/docs/ai-design/2026-03-03-fix-nonce-reset-on-refresh/manual-test-scenarios.md deleted file mode 100644 index 35c9ed6f8..000000000 --- a/docs/ai-design/2026-03-03-fix-nonce-reset-on-refresh/manual-test-scenarios.md +++ /dev/null @@ -1,61 +0,0 @@ -# Manual Test Scenarios: Fix Nonce Reset on Refresh (#652) - -## Prerequisites -- Dash Evo Tool running and connected to a network (Testnet or Devnet) -- An HD wallet with at least one Platform Payment address that has been used (nonce > 0) -- If no address has nonce > 0, perform a Platform transaction first (e.g., transfer credits) - -## Test Scenario 1: Refresh All preserves nonces (default mode) - -**Steps:** -1. Open Wallets screen -2. Select an HD wallet -3. Navigate to Platform Payment addresses (click the Platform account) -4. Note the nonce values for addresses with nonce > 0 -5. Click the Refresh button (default "Core + Platform" mode) -6. Wait for refresh to complete - -**Expected:** All nonce values remain unchanged after refresh. Addresses that had nonce > 0 still show the same nonce. - -## Test Scenario 2: Platform Only refresh preserves nonces - -**Steps:** -1. Open Wallets screen and select an HD wallet -2. Navigate to Platform Payment addresses -3. Note the nonce values -4. Switch refresh mode to "Platform Only" (dev mode dropdown) -5. Click Refresh -6. Wait for refresh to complete - -**Expected:** Nonce values remain unchanged. Balances may update if changed on-chain. - -## Test Scenario 3: Nonce updates correctly after new transaction - -**Steps:** -1. Open Wallets screen and note current nonce for a Platform address -2. Perform a Platform transaction using that address (e.g., transfer credits) -3. After transaction completes, note the updated nonce -4. Click Refresh -5. Wait for refresh to complete - -**Expected:** Nonce reflects the post-transaction value both before and after refresh. - -## Test Scenario 4: Zero-balance addresses retain nonces - -**Steps:** -1. Have a Platform address that was previously funded (nonce > 0) but now has 0 balance (credits were withdrawn or transferred out) -2. Navigate to Platform Payment addresses -3. Enable "Show zero-balance addresses" if the address is hidden -4. Note the nonce value -5. Click Refresh - -**Expected:** The address retains its nonce value even though balance is 0. - -## Test Scenario 5: Locked wallet shows error on refresh - -**Steps:** -1. Open a password-protected wallet -2. Lock the wallet -3. Attempt to click Refresh for Platform sync - -**Expected:** Error message indicating wallet must be unlocked. Nonces from before locking are unchanged. diff --git a/docs/ai-design/2026-03-05-banner-details-overlap/manual-test.md b/docs/ai-design/2026-03-05-banner-details-overlap/manual-test.md deleted file mode 100644 index 3d73e7f67..000000000 --- a/docs/ai-design/2026-03-05-banner-details-overlap/manual-test.md +++ /dev/null @@ -1,24 +0,0 @@ -# Manual Test: Banner Details Overlap Fix (#681) - -## Prerequisites -- Developer mode enabled (to see "Show details" links on error banners) -- Ability to trigger multiple backend errors (e.g., invalid network config, expired identity operations) - -## Test Scenario: Multiple Expanded Details - -1. Trigger 2+ error banners that include technical details (e.g., attempt operations on a disconnected network, or perform actions that produce different errors in sequence) -2. Verify all banners appear stacked vertically without overlap -3. Click "Show details" on the **first** banner — verify the details section expands inline, pushing subsequent banners down -4. Click "Show details" on the **second** banner — verify its details section also expands without overlapping the first -5. Scroll within each details section independently — verify scroll positions are independent (no shared state) -6. Click "Hide details" on one banner — verify only that banner's details collapse; the other remains expanded -7. Dismiss one banner — verify remaining banners reflow correctly - -## Expected Result -- Each banner's details section occupies its own vertical space -- No visual overlap between expanded details of different banners -- Scroll areas within each details section are independent - -## Regression Check -- Single banner with "Show details" still works as before -- Banners without details are unaffected From acf405808ac97f3b4d9f309795994153b0a041d9 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 10:55:43 +0100 Subject: [PATCH 098/147] fix(ui): include zero-balance addresses in AddressInput wallet entries Pull Core addresses from known_addresses (all derived) instead of address_balances (only funded). Balance is looked up from address_balances, defaulting to 0. Callers use with_balance_range(1..) when they need funded-only addresses. Fixes Mine dialog showing empty dropdown for fresh/zero-balance wallets. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/components/address_input.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/ui/components/address_input.rs b/src/ui/components/address_input.rs index 3e1d84c39..dcc883981 100644 --- a/src/ui/components/address_input.rs +++ b/src/ui/components/address_input.rs @@ -238,7 +238,9 @@ impl AddressInput { /// Filter autocomplete entries by balance range (in native units). /// - /// Does not affect manual input validation. Default: no filter. + /// All known wallet addresses are included by default (including zero-balance). + /// Use `with_balance_range(1..)` to show only funded addresses. + /// Does not affect manual input validation. Default: no filter (all addresses). pub fn with_balance_range(mut self, range: impl std::ops::RangeBounds) -> Self { self.balance_range = Some(BalanceRange::from_range(&range)); self @@ -348,8 +350,12 @@ impl AddressInput { String::new() }; - // Core addresses from address_balances - for (address, &balance) in &guard.address_balances { + // Core addresses from known_addresses (all derived addresses). + // Balance is looked up from address_balances; addresses without UTXOs + // get balance 0. Use `with_balance_range(1..)` to show only funded + // addresses — do NOT filter at the data source. + for address in guard.known_addresses.keys() { + let balance = guard.address_balances.get(address).copied().unwrap_or(0); let addr_str = address.to_string(); let display = if self.full_addresses { format!("{}{}", prefix, addr_str) From 0e1a958dcdd46c419675c57a89dff90665a9f202 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 10:57:43 +0100 Subject: [PATCH 099/147] fix(ui): sort AddressInput wallet entries alphabetically Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/components/address_input.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/ui/components/address_input.rs b/src/ui/components/address_input.rs index dcc883981..e298082f1 100644 --- a/src/ui/components/address_input.rs +++ b/src/ui/components/address_input.rs @@ -393,6 +393,10 @@ impl AddressInput { }); } } + + // Sort alphabetically by address string for consistent dropdown order. + self.all_entries + .sort_by(|a, b| a.address_string.cmp(&b.address_string)); } fn extract_identity_entries(&mut self, identities: &[QualifiedIdentity]) { From 790453c9d4ecb25b2ceb68e29df9135d5a8590bc Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 10:58:43 +0100 Subject: [PATCH 100/147] revert: remove redundant sort in extract_wallet_entries filtered_entries() already sorts alphabetically by display_label. The sort added in extract_wallet_entries was redundant. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/components/address_input.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/ui/components/address_input.rs b/src/ui/components/address_input.rs index e298082f1..68a36c630 100644 --- a/src/ui/components/address_input.rs +++ b/src/ui/components/address_input.rs @@ -394,9 +394,6 @@ impl AddressInput { } } - // Sort alphabetically by address string for consistent dropdown order. - self.all_entries - .sort_by(|a, b| a.address_string.cmp(&b.address_string)); } fn extract_identity_entries(&mut self, identities: &[QualifiedIdentity]) { From 005cadf7bcd80ee1883e2e12cefea49f15e60596 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:04:32 +0100 Subject: [PATCH 101/147] fix(ui): clarify shielded network validation logic Replace double-negation with explicit mainnet-class comparison. Documents that testnet/devnet/local share the same HRP ("tdash1z") and cannot be distinguished at the address level. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/components/address_input.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/ui/components/address_input.rs b/src/ui/components/address_input.rs index 68a36c630..e79f4a299 100644 --- a/src/ui/components/address_input.rs +++ b/src/ui/components/address_input.rs @@ -581,9 +581,12 @@ impl AddressInput { use dash_sdk::dpp::address_funds::OrchardAddress; match OrchardAddress::from_bech32m_string(trimmed) { Ok((_, network)) => { - if network != self.network - && !(self.network != Network::Mainnet && network != Network::Mainnet) - { + // Shielded addresses only encode mainnet vs non-mainnet in the HRP. + // Testnet, Devnet, and Local all share "tdash1z" and cannot be + // distinguished at the address level. Enforce mainnet isolation only. + let same_mainnet_class = + (self.network == Network::Mainnet) == (network == Network::Mainnet); + if !same_mainnet_class { ( Some("This address belongs to a different network.".to_string()), None, From 92f2e70b07f69fb3231560616012aa01ddb62051 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:07:41 +0100 Subject: [PATCH 102/147] fix(ui): include send-all transactions in wallet history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Check both outputs and inputs when filtering transactions. Build an OutPoint→Address lookup from the wallet's own transactions to resolve input addresses. Send-all transactions (no change output) were previously dropped because no output matched known_addresses. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/wallets_screen/mod.rs | 38 +++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index f1b7b8990..adee0a4ec 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -1534,20 +1534,46 @@ impl WalletsBalancesScreen { } // Filter transactions to only those involving this wallet's addresses. - // We check outputs only — transactions are already fetched per-wallet - // from SPV/RPC, so inputs are implicitly relevant. The output filter - // only excludes transactions that leaked from other wallets' data. + // Check both outputs (receives/change) and inputs (spends) so that + // send-all transactions with no change output are not dropped. let relevant_indices = self.cached_tx_indices.get_or_insert_with(|| { let wallet_addresses: std::collections::HashSet<&Address> = wallet_guard.known_addresses.keys().collect(); + + // Build a lookup from OutPoint → Address so we can resolve input addresses + // from previous transactions in the same wallet. + let mut outpoint_addresses: std::collections::HashMap< + dash_sdk::dpp::dashcore::OutPoint, + Address, + > = std::collections::HashMap::new(); + let network = self.app_context.network; + for wtx in &wallet_guard.transactions { + for (vout, output) in wtx.transaction.output.iter().enumerate() { + if let Ok(addr) = Address::from_script(&output.script_pubkey, network) { + outpoint_addresses.insert( + dash_sdk::dpp::dashcore::OutPoint::new(wtx.txid, vout as u32), + addr, + ); + } + } + } + (0..wallet_guard.transactions.len()) .filter(|&i| { let tx = &wallet_guard.transactions[i]; - tx.transaction.output.iter().any(|output| { - Address::from_script(&output.script_pubkey, self.app_context.network) + // Check outputs (receives, change) + let output_match = tx.transaction.output.iter().any(|output| { + Address::from_script(&output.script_pubkey, network) .ok() .is_some_and(|addr| wallet_addresses.contains(&addr)) - }) + }); + // Check inputs (spends) via the outpoint lookup + let input_match = tx.transaction.input.iter().any(|input| { + outpoint_addresses + .get(&input.previous_output) + .is_some_and(|addr| wallet_addresses.contains(addr)) + }); + output_match || input_match }) .collect() }); From 3cd095e2992195c74704772872a245a7c62331b9 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:10:30 +0100 Subject: [PATCH 103/147] fix(ui): use is_ours flag for transaction filtering Simplify transaction filter to use the is_ours flag instead of address matching. Fix SPV path to set is_ours=true for all wallet transactions (upstream only sets it for sends). SPV history is per-wallet, so all entries are ours by definition. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/context/wallet_lifecycle.rs | 5 +++- src/ui/wallets/wallets_screen/mod.rs | 44 +++------------------------- 2 files changed, 8 insertions(+), 41 deletions(-) diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index 30c26cd08..d1c859e7d 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -900,7 +900,10 @@ impl AppContext { net_amount: record.net_amount, fee: record.fee, label: record.label.clone(), - is_ours: record.is_ours, + // SPV transaction history is per-wallet — all entries + // involve our addresses. Upstream sets is_ours only for + // sends (net_amount < 0); we override to true for all. + is_ours: true, status, } }) diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index adee0a4ec..236c9874b 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -1533,48 +1533,12 @@ impl WalletsBalancesScreen { return; } - // Filter transactions to only those involving this wallet's addresses. - // Check both outputs (receives/change) and inputs (spends) so that - // send-all transactions with no change output are not dropped. + // Filter to transactions involving this wallet's addresses. + // The `is_ours` flag is set by both RPC and SPV paths for all + // transactions that belong to this wallet (sends and receives). let relevant_indices = self.cached_tx_indices.get_or_insert_with(|| { - let wallet_addresses: std::collections::HashSet<&Address> = - wallet_guard.known_addresses.keys().collect(); - - // Build a lookup from OutPoint → Address so we can resolve input addresses - // from previous transactions in the same wallet. - let mut outpoint_addresses: std::collections::HashMap< - dash_sdk::dpp::dashcore::OutPoint, - Address, - > = std::collections::HashMap::new(); - let network = self.app_context.network; - for wtx in &wallet_guard.transactions { - for (vout, output) in wtx.transaction.output.iter().enumerate() { - if let Ok(addr) = Address::from_script(&output.script_pubkey, network) { - outpoint_addresses.insert( - dash_sdk::dpp::dashcore::OutPoint::new(wtx.txid, vout as u32), - addr, - ); - } - } - } - (0..wallet_guard.transactions.len()) - .filter(|&i| { - let tx = &wallet_guard.transactions[i]; - // Check outputs (receives, change) - let output_match = tx.transaction.output.iter().any(|output| { - Address::from_script(&output.script_pubkey, network) - .ok() - .is_some_and(|addr| wallet_addresses.contains(&addr)) - }); - // Check inputs (spends) via the outpoint lookup - let input_match = tx.transaction.input.iter().any(|input| { - outpoint_addresses - .get(&input.previous_output) - .is_some_and(|addr| wallet_addresses.contains(addr)) - }); - output_match || input_match - }) + .filter(|&i| wallet_guard.transactions[i].is_ours) .collect() }); From e363c6da12baee7b386ac6ca3095fd41fea3d1ae Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:14:28 +0100 Subject: [PATCH 104/147] test(e2e): verify is_ours flag for SPV send and receive transactions Sends funds between two wallets via SPV and asserts that both the sender (negative net_amount) and receiver (positive net_amount) have is_ours=true. Validates the fix to the SPV reconcile path that overrides the upstream library's send-only is_ours logic. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/backend-e2e/main.rs | 1 + tests/backend-e2e/tx_is_ours.rs | 127 ++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 tests/backend-e2e/tx_is_ours.rs diff --git a/tests/backend-e2e/main.rs b/tests/backend-e2e/main.rs index 2363aea40..e16cd371e 100644 --- a/tests/backend-e2e/main.rs +++ b/tests/backend-e2e/main.rs @@ -16,3 +16,4 @@ mod identity_withdraw; mod register_dpns; mod send_funds; mod spv_wallet; +mod tx_is_ours; diff --git a/tests/backend-e2e/tx_is_ours.rs b/tests/backend-e2e/tx_is_ours.rs new file mode 100644 index 000000000..2f548f042 --- /dev/null +++ b/tests/backend-e2e/tx_is_ours.rs @@ -0,0 +1,127 @@ +//! Test: Verify `is_ours` flag is set correctly for SPV transactions. +//! +//! SPV transactions pass through bloom filter → `check_transaction()` (address +//! matching) → `record_transaction()`. The upstream library sets `is_ours` only +//! for sends (`net_amount < 0`). We override to `true` for all matched +//! transactions in the SPV reconcile path, since `check_transaction` already +//! verified address ownership (bloom filter FPs are filtered there). +//! +//! This test sends funds between two wallets and verifies that both the sender +//! and receiver have `is_ours: true` on the resulting transaction. + +use crate::framework::harness::ctx; +use crate::framework::identity_helpers::get_receive_address; +use crate::framework::task_runner::run_task; +use crate::framework::wait::{wait_for_balance, wait_for_spendable_balance}; +use dash_evo_tool::backend_task::core::{CoreTask, PaymentRecipient, WalletPaymentRequest}; +use dash_evo_tool::backend_task::{BackendTask, BackendTaskSuccessResult}; +use std::time::Duration; + +/// After an SPV send, both sender and receiver wallets must have `is_ours: true` +/// on the resulting transaction. +#[ignore] +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn test_spv_transactions_is_ours_flag() { + let ctx = ctx().await; + let app_context = &ctx.app_context; + + // Create two funded wallets + let (hash_a, wallet_a) = ctx.create_funded_test_wallet(3_000_000).await; + let (hash_b, wallet_b) = ctx.create_funded_test_wallet(1_000_000).await; + + let send_amount: u64 = 500_000; + let b_address = get_receive_address(app_context, &wallet_b); + + // Wait for A to have spendable funds + wait_for_spendable_balance(app_context, hash_a, send_amount, Duration::from_secs(120)) + .await + .expect("Wallet A should have spendable funds"); + + // Send from A to B + let request = WalletPaymentRequest { + recipients: vec![PaymentRecipient { + address: b_address.clone(), + amount_duffs: send_amount, + }], + subtract_fee_from_amount: false, + memo: Some("is_ours test".to_string()), + override_fee: None, + }; + + let task = BackendTask::CoreTask(CoreTask::SendWalletPayment { + wallet: wallet_a.clone(), + request, + }); + + let result = run_task(app_context, task) + .await + .expect("Payment A->B should succeed"); + + let payment_txid = match &result { + BackendTaskSuccessResult::WalletPayment { txid, .. } => { + tracing::info!("Payment txid: {txid}"); + txid.clone() + } + other => panic!("Expected WalletPayment, got: {other:?}"), + }; + + // Wait for B to receive the funds (ensures SPV has propagated the tx) + let initial_b = { + let w = wallet_b.read().expect("lock"); + w.total_balance_duffs() + }; + wait_for_balance( + app_context, + hash_b, + initial_b + send_amount, + Duration::from_secs(120), + ) + .await + .expect("B should receive funds"); + + // Force a reconcile to ensure latest SPV state is reflected + app_context + .reconcile_spv_wallets() + .await + .expect("reconcile should succeed"); + + // Check is_ours on wallet A (sender) — should be true + { + let wallets = app_context.wallets().read().expect("wallets lock"); + let wallet = wallets.get(&hash_a).expect("wallet A").read().expect("lock"); + let tx = wallet + .transactions + .iter() + .find(|t| t.txid.to_string() == payment_txid) + .unwrap_or_else(|| panic!("Wallet A should have tx {payment_txid}")); + assert!( + tx.is_ours, + "Sender wallet should have is_ours=true for outgoing tx {payment_txid}" + ); + assert!( + tx.net_amount < 0, + "Sender tx should have negative net_amount" + ); + } + + // Check is_ours on wallet B (receiver) — should be true + { + let wallets = app_context.wallets().read().expect("wallets lock"); + let wallet = wallets.get(&hash_b).expect("wallet B").read().expect("lock"); + let tx = wallet + .transactions + .iter() + .find(|t| t.txid.to_string() == payment_txid) + .unwrap_or_else(|| panic!("Wallet B should have tx {payment_txid}")); + assert!( + tx.is_ours, + "Receiver wallet should have is_ours=true for incoming tx {payment_txid}" + ); + assert!( + tx.net_amount > 0, + "Receiver tx should have positive net_amount" + ); + } + + tracing::info!("is_ours flag verified for both sender and receiver"); +} From 24256db50b0b3df5ec1526693a423e6c6cccba30 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:30:24 +0100 Subject: [PATCH 105/147] fix(core): clear RPC error state on successful chain lock fetch When get_best_chain_lock() succeeds on the active network, explicitly clear any lingering RPC error in ConnectionStatus so the connection indicator recovers after a transient outage. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend_task/core/mod.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/backend_task/core/mod.rs b/src/backend_task/core/mod.rs index 05085e08a..cc301c35f 100644 --- a/src/backend_task/core/mod.rs +++ b/src/backend_task/core/mod.rs @@ -202,6 +202,9 @@ impl AppContext { tracing::warn!(network = ?self.network, error = %e, "Chain lock query failed on active network"); Some("RPC error — check Dash Core status".to_string()) } else { + // Successful chain lock fetch — clear any lingering RPC error + // so the connection status recovers after a transient outage. + self.connection_status.set_rpc_last_error(None); None }; From 4187fff368b956c21fdef5f1feac29227ec5be86 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:31:01 +0100 Subject: [PATCH 106/147] fix(model): move is_platform_address_string from UI helpers to model layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AddressKind::detect() in the model layer depended on crate::ui::helpers for platform address detection — wrong layering. Move the function to src/model/address.rs and keep a re-export in helpers for existing callers. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/model/address.rs | 19 +++++++++++++++++-- src/ui/helpers.rs | 19 +++---------------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src/model/address.rs b/src/model/address.rs index 7e4180a1e..c49c58310 100644 --- a/src/model/address.rs +++ b/src/model/address.rs @@ -2,10 +2,25 @@ use dash_sdk::dashcore_rpc::dashcore::Address; #[cfg(test)] use dash_sdk::dashcore_rpc::dashcore::Network; use dash_sdk::dashcore_rpc::dashcore::address::NetworkUnchecked; -use dash_sdk::dpp::address_funds::PlatformAddress; +use dash_sdk::dpp::address_funds::{PLATFORM_HRP_MAINNET, PLATFORM_HRP_TESTNET, PlatformAddress}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::Identifier; +/// Checks if a string looks like a Platform address (bech32m with dash/tdash HRP per DIP-18). +/// +/// This checks whether the string starts with a known Platform HRP followed by the +/// bech32 separator '1'. It does NOT fully validate the address — use +/// `PlatformAddress::from_bech32m_string()` for that. +pub fn is_platform_address_string(s: &str) -> bool { + let s = s.to_lowercase(); + for hrp in [PLATFORM_HRP_MAINNET, PLATFORM_HRP_TESTNET] { + if s.starts_with(hrp) && s.get(hrp.len()..hrp.len() + 1) == Some("1") { + return true; + } + } + false +} + /// Classification of a Dash address for filtering and display purposes. /// /// This enum represents the four recognized address categories. It is used @@ -72,7 +87,7 @@ impl AddressKind { } // 2. Platform (Bech32m per DIP-18, but NOT shielded — already excluded above) - if crate::ui::helpers::is_platform_address_string(trimmed) { + if is_platform_address_string(trimmed) { return Some(AddressKind::Platform); } diff --git a/src/ui/helpers.rs b/src/ui/helpers.rs index 7b4224718..a522eb721 100644 --- a/src/ui/helpers.rs +++ b/src/ui/helpers.rs @@ -1,7 +1,9 @@ use crate::ui::theme::ResponseExt; -use dash_sdk::dpp::address_funds::{PLATFORM_HRP_MAINNET, PLATFORM_HRP_TESTNET}; use std::sync::Arc; +// Re-export from the model layer so existing callers don't break. +pub use crate::model::address::is_platform_address_string; + /// Returns true if the user left-clicked outside the given window rect this frame. /// Use after painting a modal overlay and showing the dialog window. pub fn clicked_outside_window(ctx: &egui::Context, window_rect: egui::Rect) -> bool { @@ -13,21 +15,6 @@ pub fn clicked_outside_window(ctx: &egui::Context, window_rect: egui::Rect) -> b }) } -/// Checks if a string looks like a Platform address (bech32m with dash/tdash HRP per DIP-18). -/// -/// This checks whether the string starts with a known Platform HRP followed by the -/// bech32 separator '1'. It does NOT fully validate the address — use -/// `PlatformAddress::from_bech32m_string()` for that. -pub fn is_platform_address_string(s: &str) -> bool { - let s = s.to_lowercase(); - for hrp in [PLATFORM_HRP_MAINNET, PLATFORM_HRP_TESTNET] { - if s.starts_with(hrp) && s.get(hrp.len()..hrp.len() + 1) == Some("1") { - return true; - } - } - false -} - use crate::{ app::AppAction, context::AppContext, From bb0c6dc383ec2b00d0cc7d708d6a5ab917b33fcb Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:31:17 +0100 Subject: [PATCH 107/147] fix(ui): add actionable guidance to address validation error messages Each validation error now tells the user what to do, not just what went wrong: "check for typos", "check you are using the correct network", "use all lowercase". Messages that already had guidance are unchanged. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/components/address_input.rs | 32 +++++++++++++++--------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/ui/components/address_input.rs b/src/ui/components/address_input.rs index e79f4a299..c5ae29914 100644 --- a/src/ui/components/address_input.rs +++ b/src/ui/components/address_input.rs @@ -472,7 +472,7 @@ impl AddressInput { if detected == DetectedType::Unknown { return ( - Some("This does not look like a valid address.".to_string()), + Some("This does not look like a valid address. Please check for typos.".to_string()), None, ); } @@ -506,12 +506,12 @@ impl AddressInput { Ok(addr) => match addr.require_network(self.network) { Ok(checked) => (None, Some(ValidatedAddress::Core(checked))), Err(_) => ( - Some("This address belongs to a different network.".to_string()), + Some("This address belongs to a different network. Please check you are using the correct network.".to_string()), None, ), }, Err(_) => ( - Some("This does not look like a valid address.".to_string()), + Some("This does not look like a valid address. Please check for typos.".to_string()), None, ), } @@ -524,7 +524,7 @@ impl AddressInput { if !is_lower && !is_upper { return ( Some( - "Platform addresses must not mix upper and lower case characters.".to_string(), + "Platform addresses must not mix upper and lower case characters. Please use all lowercase.".to_string(), ), None, ); @@ -538,7 +538,7 @@ impl AddressInput { || canonical.starts_with(&format!("{}z", expected_prefix)) { return ( - Some("This address belongs to a different network.".to_string()), + Some("This address belongs to a different network. Please check you are using the correct network.".to_string()), None, ); } @@ -551,7 +551,7 @@ impl AddressInput { }), ), Err(_) => ( - Some("This does not look like a valid address.".to_string()), + Some("This does not look like a valid address. Please check for typos.".to_string()), None, ), } @@ -564,7 +564,7 @@ impl AddressInput { }; if !trimmed.starts_with(expected_prefix) { return ( - Some("This address belongs to a different network.".to_string()), + Some("This address belongs to a different network. Please check you are using the correct network.".to_string()), None, ); } @@ -588,7 +588,7 @@ impl AddressInput { (self.network == Network::Mainnet) == (network == Network::Mainnet); if !same_mainnet_class { ( - Some("This address belongs to a different network.".to_string()), + Some("This address belongs to a different network. Please check you are using the correct network.".to_string()), None, ) } else { @@ -627,7 +627,7 @@ impl AddressInput { ) } Err(_) => ( - Some("This does not look like a valid address.".to_string()), + Some("This does not look like a valid address. Please check for typos.".to_string()), None, ), } @@ -1173,7 +1173,7 @@ mod tests { assert!(val.is_none()); assert_eq!( err.as_deref(), - Some("This address belongs to a different network.") + Some("This address belongs to a different network. Please check you are using the correct network.") ); } @@ -1194,7 +1194,7 @@ mod tests { assert!(val.is_none()); assert_eq!( err.as_deref(), - Some("This address belongs to a different network.") + Some("This address belongs to a different network. Please check you are using the correct network.") ); } @@ -1205,7 +1205,7 @@ mod tests { assert!(val.is_none()); assert_eq!( err.as_deref(), - Some("This address belongs to a different network.") + Some("This address belongs to a different network. Please check you are using the correct network.") ); } @@ -1323,7 +1323,7 @@ mod tests { assert!(val.is_none()); assert_eq!( err.as_deref(), - Some("This does not look like a valid address.") + Some("This does not look like a valid address. Please check for typos.") ); } @@ -1502,7 +1502,7 @@ mod tests { assert!(val.is_none(), "mixed-case bech32m should be rejected"); assert_eq!( err.as_deref(), - Some("Platform addresses must not mix upper and lower case characters.") + Some("Platform addresses must not mix upper and lower case characters. Please use all lowercase.") ); } @@ -1513,7 +1513,7 @@ mod tests { let (err, _) = input.validate_platform("tdash1qwer1234"); assert_ne!( err.as_deref(), - Some("Platform addresses must not mix upper and lower case characters."), + Some("Platform addresses must not mix upper and lower case characters. Please use all lowercase."), "all-lowercase should pass the case check" ); } @@ -1525,7 +1525,7 @@ mod tests { let (err, _) = input.validate_platform("TDASH1QWER1234"); assert_ne!( err.as_deref(), - Some("Platform addresses must not mix upper and lower case characters."), + Some("Platform addresses must not mix upper and lower case characters. Please use all lowercase."), "all-uppercase should pass the case check" ); } From f55b1380117ebbac045d213d247a743caddfd0e5 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:32:11 +0100 Subject: [PATCH 108/147] fix(ui): invalidate address inputs on all screens during context switch TransferScreen, AddressBalanceScreen, and ShieldedSendScreen retained stale address text from the previous network after a context switch. Add invalidate_address_input() to each and call it from change_context(). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/identities/transfer_screen.rs | 4 ++++ src/ui/mod.rs | 15 ++++++++++++--- src/ui/tools/address_balance_screen.rs | 5 +++++ src/ui/wallets/shielded_send_screen.rs | 4 ++++ 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/ui/identities/transfer_screen.rs b/src/ui/identities/transfer_screen.rs index 7eac3f453..b22ede8f1 100644 --- a/src/ui/identities/transfer_screen.rs +++ b/src/ui/identities/transfer_screen.rs @@ -121,6 +121,10 @@ impl TransferScreen { } } + pub(crate) fn invalidate_address_input(&mut self) { + self.platform_address_input.clear(); + } + fn render_key_selection(&mut self, ui: &mut Ui) -> AppAction { add_key_chooser( ui, diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 502a0b4f1..1165e9e3a 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -803,7 +803,10 @@ impl Screen { screen.app_context = app_context; screen.reset_core_wallets_cache(); } - Screen::TransferScreen(screen) => screen.app_context = app_context, + Screen::TransferScreen(screen) => { + screen.app_context = app_context; + screen.invalidate_address_input(); + } Screen::TopUpIdentityScreen(screen) => screen.app_context = app_context, Screen::WalletsBalancesScreen(screen) => { screen.app_context = app_context; @@ -836,7 +839,10 @@ impl Screen { 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::AddressBalanceScreen(screen) => { + screen.app_context = app_context; + screen.invalidate_address_input(); + } // Token Screens Screen::TokensScreen(screen) => screen.app_context = app_context, @@ -875,7 +881,10 @@ impl Screen { // Shielded screens Screen::ShieldCreditsScreen(screen) => screen.app_context = app_context.clone(), Screen::ShieldFromAssetLockScreen(screen) => screen.app_context = app_context.clone(), - Screen::ShieldedSendScreen(screen) => screen.app_context = app_context.clone(), + Screen::ShieldedSendScreen(screen) => { + screen.app_context = app_context.clone(); + screen.invalidate_address_input(); + } Screen::UnshieldCreditsScreen(screen) => { screen.app_context = app_context.clone(); screen.invalidate_address_input(); diff --git a/src/ui/tools/address_balance_screen.rs b/src/ui/tools/address_balance_screen.rs index 165aeccae..ef85636fe 100644 --- a/src/ui/tools/address_balance_screen.rs +++ b/src/ui/tools/address_balance_screen.rs @@ -35,6 +35,11 @@ impl AddressBalanceScreen { } } + pub(crate) fn invalidate_address_input(&mut self) { + self.address_input.clear(); + self.result = None; + } + fn trigger_fetch(&mut self) -> AppAction { let address = self.address_input.trim().to_string(); if address.is_empty() { diff --git a/src/ui/wallets/shielded_send_screen.rs b/src/ui/wallets/shielded_send_screen.rs index b23408981..503c2259c 100644 --- a/src/ui/wallets/shielded_send_screen.rs +++ b/src/ui/wallets/shielded_send_screen.rs @@ -64,6 +64,10 @@ impl ShieldedSendScreen { } } + pub(crate) fn invalidate_address_input(&mut self) { + self.recipient_address_input.clear(); + } + fn validate_recipient(&self) -> Option> { let trimmed = self.recipient_address_input.trim(); if trimmed.is_empty() { From 70463582f405b458a9f50963ee78ada4eeabd3a3 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:32:29 +0100 Subject: [PATCH 109/147] fix(ui): clear password field on network switch when no config exists When switching networks, if no config entry exists for the new network (or the password is empty), the password input field now clears instead of retaining the previous network's password. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/network_chooser_screen.rs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index 7e70f8822..02a9eb635 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -415,14 +415,17 @@ impl NetworkChooserScreen { { app_action = AppAction::SwitchNetwork(Network::Regtest); } - if self.current_network != prev_network - && let Ok(config) = - Config::load_from(&self.mainnet_app_context.data_dir) - && let Some(network_config) = - config.config_for_network(self.current_network) - { - self.dashmate_password_input - .set_text(network_config.core_rpc_password.clone()); + if self.current_network != prev_network { + let password = Config::load_from( + &self.mainnet_app_context.data_dir, + ) + .ok() + .and_then(|c| { + c.config_for_network(self.current_network).cloned() + }) + .map(|nc| nc.core_rpc_password) + .unwrap_or_default(); + self.dashmate_password_input.set_text(password); } }); }); From 1dcf070b1166aad1aea5cdc660a3db6a3301825f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:32:50 +0100 Subject: [PATCH 110/147] fix(ui): clear validated_destination in invalidate_address_input WalletSendScreen and UnshieldCreditsScreen cleared the AddressInput widget but left validated_destination with stale validation from the previous network. Now both are cleared together. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/send_screen.rs | 1 + src/ui/wallets/unshield_credits_screen.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index 45c476a86..62dc390cc 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -471,6 +471,7 @@ impl WalletSendScreen { /// Clear the AddressInput widget so it picks up the new network on next frame. pub(crate) fn invalidate_address_input(&mut self) { self.address_input = None; + self.validated_destination = None; } fn reset_form(&mut self) { diff --git a/src/ui/wallets/unshield_credits_screen.rs b/src/ui/wallets/unshield_credits_screen.rs index 9fc741ea5..eafb8183a 100644 --- a/src/ui/wallets/unshield_credits_screen.rs +++ b/src/ui/wallets/unshield_credits_screen.rs @@ -46,6 +46,7 @@ impl UnshieldCreditsScreen { /// Clear the AddressInput widget so it picks up the new network on next frame. pub(crate) fn invalidate_address_input(&mut self) { self.address_input = None; + self.validated_destination = None; } pub fn new(seed_hash: WalletSeedHash, app_context: &Arc) -> Self { From eaccb8239d5bd8b0b5f3dab8c81d35d18b9c30e3 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:33:04 +0100 Subject: [PATCH 111/147] fix(ui): consume ShieldedNotesSynced to update shielded send screen state After a shielded transfer, the post-transfer note sync result was only logged but never used to update the UI. Now ShieldedNotesSynced updates the balance display, clears the pending-update banner, and appends the remaining balance to the success message. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/shielded_send_screen.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/ui/wallets/shielded_send_screen.rs b/src/ui/wallets/shielded_send_screen.rs index 503c2259c..1de3aa162 100644 --- a/src/ui/wallets/shielded_send_screen.rs +++ b/src/ui/wallets/shielded_send_screen.rs @@ -247,6 +247,15 @@ impl ScreenLike for ShieldedSendScreen { new_notes, balance, ); + self.max_balance = balance; + self.balance_update_pending = false; + let dash = balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; + if let Some(msg) = self.success_message.as_mut() { + *msg = format!( + "{}\nBalance updated: {:.8} DASH remaining.", + msg, dash, + ); + } } _ => {} } From 6a43e7c81c663f0fa20a98fb76198321cbf73678 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:34:45 +0100 Subject: [PATCH 112/147] style: apply cargo +nightly fmt formatting Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/components/address_input.rs | 37 +++++++++++++++++++------- src/ui/network_chooser_screen.rs | 5 ++-- src/ui/wallets/shielded_send_screen.rs | 5 +--- 3 files changed, 31 insertions(+), 16 deletions(-) diff --git a/src/ui/components/address_input.rs b/src/ui/components/address_input.rs index c5ae29914..654f3d5c0 100644 --- a/src/ui/components/address_input.rs +++ b/src/ui/components/address_input.rs @@ -393,7 +393,6 @@ impl AddressInput { }); } } - } fn extract_identity_entries(&mut self, identities: &[QualifiedIdentity]) { @@ -472,7 +471,9 @@ impl AddressInput { if detected == DetectedType::Unknown { return ( - Some("This does not look like a valid address. Please check for typos.".to_string()), + Some( + "This does not look like a valid address. Please check for typos.".to_string(), + ), None, ); } @@ -551,7 +552,9 @@ impl AddressInput { }), ), Err(_) => ( - Some("This does not look like a valid address. Please check for typos.".to_string()), + Some( + "This does not look like a valid address. Please check for typos.".to_string(), + ), None, ), } @@ -627,7 +630,9 @@ impl AddressInput { ) } Err(_) => ( - Some("This does not look like a valid address. Please check for typos.".to_string()), + Some( + "This does not look like a valid address. Please check for typos.".to_string(), + ), None, ), } @@ -1173,7 +1178,9 @@ mod tests { assert!(val.is_none()); assert_eq!( err.as_deref(), - Some("This address belongs to a different network. Please check you are using the correct network.") + Some( + "This address belongs to a different network. Please check you are using the correct network." + ) ); } @@ -1194,7 +1201,9 @@ mod tests { assert!(val.is_none()); assert_eq!( err.as_deref(), - Some("This address belongs to a different network. Please check you are using the correct network.") + Some( + "This address belongs to a different network. Please check you are using the correct network." + ) ); } @@ -1205,7 +1214,9 @@ mod tests { assert!(val.is_none()); assert_eq!( err.as_deref(), - Some("This address belongs to a different network. Please check you are using the correct network.") + Some( + "This address belongs to a different network. Please check you are using the correct network." + ) ); } @@ -1502,7 +1513,9 @@ mod tests { assert!(val.is_none(), "mixed-case bech32m should be rejected"); assert_eq!( err.as_deref(), - Some("Platform addresses must not mix upper and lower case characters. Please use all lowercase.") + Some( + "Platform addresses must not mix upper and lower case characters. Please use all lowercase." + ) ); } @@ -1513,7 +1526,9 @@ mod tests { let (err, _) = input.validate_platform("tdash1qwer1234"); assert_ne!( err.as_deref(), - Some("Platform addresses must not mix upper and lower case characters. Please use all lowercase."), + Some( + "Platform addresses must not mix upper and lower case characters. Please use all lowercase." + ), "all-lowercase should pass the case check" ); } @@ -1525,7 +1540,9 @@ mod tests { let (err, _) = input.validate_platform("TDASH1QWER1234"); assert_ne!( err.as_deref(), - Some("Platform addresses must not mix upper and lower case characters. Please use all lowercase."), + Some( + "Platform addresses must not mix upper and lower case characters. Please use all lowercase." + ), "all-uppercase should pass the case check" ); } diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index 02a9eb635..175fcc639 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -421,9 +421,10 @@ impl NetworkChooserScreen { ) .ok() .and_then(|c| { - c.config_for_network(self.current_network).cloned() + c.config_for_network(self.current_network) + .as_ref() + .map(|nc| nc.core_rpc_password.clone()) }) - .map(|nc| nc.core_rpc_password) .unwrap_or_default(); self.dashmate_password_input.set_text(password); } diff --git a/src/ui/wallets/shielded_send_screen.rs b/src/ui/wallets/shielded_send_screen.rs index 1de3aa162..cf6b4263f 100644 --- a/src/ui/wallets/shielded_send_screen.rs +++ b/src/ui/wallets/shielded_send_screen.rs @@ -251,10 +251,7 @@ impl ScreenLike for ShieldedSendScreen { self.balance_update_pending = false; let dash = balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; if let Some(msg) = self.success_message.as_mut() { - *msg = format!( - "{}\nBalance updated: {:.8} DASH remaining.", - msg, dash, - ); + *msg = format!("{}\nBalance updated: {:.8} DASH remaining.", msg, dash,); } } _ => {} From 5e4df1c21d1a9fa5608fc76961e994a68b185d9c Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:48:18 +0100 Subject: [PATCH 113/147] fix(model): disambiguate Core vs Identity address detection by prefix Core addresses on Dash start with X/Y (mainnet) or y/8/7 (testnet). When the input starts with a known Core prefix, try Core first. Otherwise try Identity first, since Identity IDs use the same Base58 alphabet but don't have Core address prefixes. This fixes identity-only AddressInput mode rejecting valid Identity IDs that happened to parse as Core addresses, and eliminates the flaky test that had to skip Core-looking random identifiers. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/model/address.rs | 82 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 67 insertions(+), 15 deletions(-) diff --git a/src/model/address.rs b/src/model/address.rs index c49c58310..3e954a537 100644 --- a/src/model/address.rs +++ b/src/model/address.rs @@ -91,14 +91,32 @@ impl AddressKind { return Some(AddressKind::Platform); } - // 3. Core (Base58Check) - if trimmed.parse::>().is_ok() { - return Some(AddressKind::Core); - } + // 3 & 4. Core vs Identity disambiguation. + // + // Both Core addresses and Identity IDs use Base58. Core addresses + // on Dash always start with X/Y (mainnet) or y/8/7 (testnet). + // If the input starts with a known Core prefix, try Core first. + // Otherwise try Identity first to avoid misclassifying IDs as + // Core addresses (they share the Base58 alphabet). + let core_prefix = matches!( + trimmed.as_bytes().first(), + Some(b'X' | b'Y' | b'y' | b'8' | b'7') + ); - // 4. Identity (Base58 fallback) - if Identifier::from_string(trimmed, Encoding::Base58).is_ok() { - return Some(AddressKind::Identity); + if core_prefix { + if trimmed.parse::>().is_ok() { + return Some(AddressKind::Core); + } + if Identifier::from_string(trimmed, Encoding::Base58).is_ok() { + return Some(AddressKind::Identity); + } + } else { + if Identifier::from_string(trimmed, Encoding::Base58).is_ok() { + return Some(AddressKind::Identity); + } + if trimmed.parse::>().is_ok() { + return Some(AddressKind::Core); + } } None @@ -312,15 +330,49 @@ mod tests { } #[test] - fn detect_identity_base58_fallback() { - let id = Identifier::random(); - let id_str = id.to_string(Encoding::Base58); - // Some random identifiers parse as Core addresses. Skip those for - // this test — only assert identity detection for ones that do not. - if AddressKind::detect(&id_str) == Some(AddressKind::Core) { - return; + fn detect_identity_base58() { + // Identity IDs that don't start with a Core prefix (X/Y/y/8/7) + // should always detect as Identity, not Core. + for _ in 0..20 { + let id = Identifier::random(); + let id_str = id.to_string(Encoding::Base58); + let first = id_str.as_bytes()[0]; + if matches!(first, b'X' | b'Y' | b'y' | b'8' | b'7') { + // Core prefix — detection correctly prefers Core. Skip. + continue; + } + assert_eq!( + AddressKind::detect(&id_str), + Some(AddressKind::Identity), + "Non-Core-prefix identifier {id_str} should detect as Identity" + ); + } + } + + #[test] + fn detect_identity_with_core_prefix_still_works_when_not_valid_core() { + // An Identity ID that happens to start with a Core prefix but + // doesn't pass Core address parsing should still detect as Identity. + // We test this by creating identifiers until we find one starting + // with a Core prefix that isn't a valid Core address. + for _ in 0..100 { + let id = Identifier::random(); + let id_str = id.to_string(Encoding::Base58); + let first = id_str.as_bytes()[0]; + if !matches!(first, b'X' | b'Y' | b'y' | b'8' | b'7') { + continue; + } + // Has Core prefix — if it doesn't parse as Core, it should be Identity + if id_str.parse::>().is_err() { + assert_eq!( + AddressKind::detect(&id_str), + Some(AddressKind::Identity), + "Core-prefix identifier {id_str} that fails Core parse should detect as Identity" + ); + return; + } } - assert_eq!(AddressKind::detect(&id_str), Some(AddressKind::Identity)); + // If all 100 parsed as valid Core, that's fine — test is probabilistic } #[test] From 642bd58073f094cd117b0269fb8f27deeb66e8e8 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:58:01 +0100 Subject: [PATCH 114/147] fix(core): show actual RPC error on Networks page instead of generic message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Networks page is a debugging tool — users need the real error text to diagnose Dash Core issues. Replace sanitized "RPC error — check Dash Core status" with the actual error from dashcore_rpc. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend_task/core/mod.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/backend_task/core/mod.rs b/src/backend_task/core/mod.rs index cc301c35f..2136b401b 100644 --- a/src/backend_task/core/mod.rs +++ b/src/backend_task/core/mod.rs @@ -197,10 +197,10 @@ impl AppContext { if let Some(task_err) = Self::chain_lock_rpc_error(active_config, e) { return Err(task_err); } - // Non-auth, non-connection error — log the raw error but show - // a sanitized message in the UI status display. + // Non-auth, non-connection error — show the actual error + // in the Networks page status display for debugging. tracing::warn!(network = ?self.network, error = %e, "Chain lock query failed on active network"); - Some("RPC error — check Dash Core status".to_string()) + Some(format!("RPC error: {e}")) } else { // Successful chain lock fetch — clear any lingering RPC error // so the connection status recovers after a transient outage. From 3c89c3a84fb3a3a18329f62d6617688e57974531 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:03:51 +0100 Subject: [PATCH 115/147] fix(db): resolve deadlock in clear_network_data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clear_network_data() held self.conn.lock() for the transaction, then called clear_commitment_tree_tables() which tried to acquire the same mutex — classic non-reentrant mutex deadlock. UI froze on "Delete Data". Fix: scope the connection lock so it's released before the commitment tree clearing acquires it again. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/database/mod.rs | 213 +++++++++++++++++++++++--------------------- 1 file changed, 109 insertions(+), 104 deletions(-) diff --git a/src/database/mod.rs b/src/database/mod.rs index 4fc1dcdbf..a2af9431c 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -81,110 +81,115 @@ impl Database { /// Removes all application data tied to a specific Dash network. pub fn clear_network_data(&self, network: Network) -> rusqlite::Result<()> { let network_str = network.to_string(); - let mut conn = self.conn.lock().unwrap(); - let tx = conn.transaction()?; - - // Remove DashPay/contact data referencing identities from this network. - tx.execute( - "DELETE FROM dashpay_payments - WHERE from_identity_id IN (SELECT id FROM identity WHERE network = ?1) - OR to_identity_id IN (SELECT id FROM identity WHERE network = ?1)", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM dashpay_contact_requests - WHERE from_identity_id IN (SELECT id FROM identity WHERE network = ?1) - OR to_identity_id IN (SELECT id FROM identity WHERE network = ?1)", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM dashpay_contacts - WHERE owner_identity_id IN (SELECT id FROM identity WHERE network = ?1) - OR contact_identity_id IN (SELECT id FROM identity WHERE network = ?1)", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM contact_private_info - WHERE owner_identity_id IN (SELECT id FROM identity WHERE network = ?1) - OR contact_identity_id IN (SELECT id FROM identity WHERE network = ?1)", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM dashpay_profiles - WHERE identity_id IN (SELECT id FROM identity WHERE network = ?1)", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM identity_token_balances WHERE network = ?1", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM token WHERE network = ?1", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM contract WHERE network = ?1", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM scheduled_votes WHERE network = ?1", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM wallet_transactions WHERE network = ?1", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM utxos WHERE network = ?1", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM asset_lock_transaction WHERE network = ?1", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM contestant WHERE network = ?1", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM contested_name WHERE network = ?1", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM identity WHERE network = ?1", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM wallet WHERE network = ?1", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM single_key_wallet WHERE network = ?1", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM shielded_notes WHERE network = ?1", - rusqlite::params![&network_str], - )?; - - tx.commit()?; + + // Scope the connection lock so it's released before + // clear_commitment_tree_tables acquires it again. + { + let mut conn = self.conn.lock().unwrap(); + let tx = conn.transaction()?; + + // Remove DashPay/contact data referencing identities from this network. + tx.execute( + "DELETE FROM dashpay_payments + WHERE from_identity_id IN (SELECT id FROM identity WHERE network = ?1) + OR to_identity_id IN (SELECT id FROM identity WHERE network = ?1)", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM dashpay_contact_requests + WHERE from_identity_id IN (SELECT id FROM identity WHERE network = ?1) + OR to_identity_id IN (SELECT id FROM identity WHERE network = ?1)", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM dashpay_contacts + WHERE owner_identity_id IN (SELECT id FROM identity WHERE network = ?1) + OR contact_identity_id IN (SELECT id FROM identity WHERE network = ?1)", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM contact_private_info + WHERE owner_identity_id IN (SELECT id FROM identity WHERE network = ?1) + OR contact_identity_id IN (SELECT id FROM identity WHERE network = ?1)", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM dashpay_profiles + WHERE identity_id IN (SELECT id FROM identity WHERE network = ?1)", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM identity_token_balances WHERE network = ?1", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM token WHERE network = ?1", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM contract WHERE network = ?1", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM scheduled_votes WHERE network = ?1", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM wallet_transactions WHERE network = ?1", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM utxos WHERE network = ?1", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM asset_lock_transaction WHERE network = ?1", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM contestant WHERE network = ?1", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM contested_name WHERE network = ?1", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM identity WHERE network = ?1", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM wallet WHERE network = ?1", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM single_key_wallet WHERE network = ?1", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM shielded_notes WHERE network = ?1", + rusqlite::params![&network_str], + )?; + + tx.commit()?; + } // conn lock released here // Commitment tree tables are optional (created lazily by grovedb). // Log and continue if clearing them fails — the main network data From 5e934a03dee167072a9546a881cebc22c8db3ab2 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:15:08 +0100 Subject: [PATCH 116/147] fix(ui): replace mutex unwrap() with graceful .ok() in shielded_sync_task The shielded_states mutex lock used .unwrap() which would panic on a poisoned mutex. Replace with .ok()? to return None gracefully. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/wallets_screen/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 236c9874b..2d6d59c77 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -2092,7 +2092,7 @@ impl WalletsBalancesScreen { /// Returns a SyncNotes backend task if the shielded wallet has been initialized /// for the given seed hash. fn shielded_sync_task(&self, seed_hash: &WalletSeedHash) -> Option { - let states = self.app_context.shielded_states.lock().unwrap(); + let states = self.app_context.shielded_states.lock().ok()?; if states.contains_key(seed_hash) { Some(BackendTask::ShieldedTask(ShieldedTask::SyncNotes { seed_hash: *seed_hash, From d3faf5ee34afa8f2face5ca08b7275c340471c23 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:15:31 +0100 Subject: [PATCH 117/147] fix(ui): replace RwLock unwrap() with graceful error handling in shielded tab Replace wallets.read().unwrap() with .read().ok() and show an error label instead of panicking on a poisoned RwLock. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/shielded_tab.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ui/wallets/shielded_tab.rs b/src/ui/wallets/shielded_tab.rs index 2d3fcf069..e72f066e8 100644 --- a/src/ui/wallets/shielded_tab.rs +++ b/src/ui/wallets/shielded_tab.rs @@ -361,7 +361,10 @@ impl ShieldedTabView { }); } else { let wallet_locked = { - let wallets = self.app_context.wallets.read().unwrap(); + let Some(wallets) = self.app_context.wallets.read().ok() else { + ui.label("Unable to read wallet state. Please try again."); + return action; + }; wallets .get(&self.seed_hash) .is_some_and(wallet_needs_unlock) From a51fb671b9fe3baac59dcf1c5568832b096a8e62 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:15:53 +0100 Subject: [PATCH 118/147] fix(spv): add debug log when overriding is_ours for receive transactions Log at debug level when SPV reports is_ours=false for a receive transaction (net_amount >= 0) to detect upstream API behavior changes. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/context/wallet_lifecycle.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index d1c859e7d..3f1d3a597 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -903,7 +903,16 @@ impl AppContext { // SPV transaction history is per-wallet — all entries // involve our addresses. Upstream sets is_ours only for // sends (net_amount < 0); we override to true for all. - is_ours: true, + is_ours: { + if !record.is_ours && record.net_amount >= 0 { + tracing::debug!( + txid = %record.txid, + net_amount = record.net_amount, + "SPV: overriding is_ours to true for receive transaction" + ); + } + true + }, status, } }) From e2ba5fc004d6d4595e3d320a0716474bcb4487a6 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:16:18 +0100 Subject: [PATCH 119/147] fix(shielded): log warning on note value divergence during position dedup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use a HashMap instead of HashSet so we can detect when a re-synced note at an existing position has a different value — indicating potential data integrity issues. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend_task/shielded/sync.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/backend_task/shielded/sync.rs b/src/backend_task/shielded/sync.rs index d0978f220..96a512fc3 100644 --- a/src/backend_task/shielded/sync.rs +++ b/src/backend_task/shielded/sync.rs @@ -112,18 +112,27 @@ pub async fn sync_notes( // Persist and record decrypted notes that are new (position >= already_have). // Also skip notes already in memory (loaded from DB during init) to prevent // double-counting when the commitment tree resets but persisted notes remain. - // Build a HashSet of existing positions for O(1) lookups instead of O(n) scans. - let existing_positions: std::collections::HashSet = shielded_state + // Build a HashMap of position->value for O(1) lookups and divergence detection. + let existing_notes: std::collections::HashMap = shielded_state .notes .iter() - .map(|n| u64::from(n.position)) + .map(|n| (u64::from(n.position), n.note.value().inner())) .collect(); let mut new_note_count = 0u32; for dn in result.decrypted_notes { if dn.position < already_have { continue; // already stored in a previous sync } - if existing_positions.contains(&dn.position) { + if let Some(&existing_value) = existing_notes.get(&dn.position) { + let new_value = dn.note.value().inner(); + if new_value != existing_value { + tracing::warn!( + position = dn.position, + existing_value, + new_value, + "Shielded note dedup: value divergence at existing position" + ); + } continue; // already loaded from DB during init } From 47c8821fa16143c0e273ce37b9bb3049f86d3b02 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:16:36 +0100 Subject: [PATCH 120/147] perf(model): avoid allocation in is_platform_address_string Replace to_lowercase() with eq_ignore_ascii_case() for the HRP prefix check. The HRP constants are ASCII-only so this is safe and avoids a heap allocation on every call. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/model/address.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/model/address.rs b/src/model/address.rs index 3e954a537..a99f8c6b6 100644 --- a/src/model/address.rs +++ b/src/model/address.rs @@ -12,9 +12,11 @@ use dash_sdk::platform::Identifier; /// bech32 separator '1'. It does NOT fully validate the address — use /// `PlatformAddress::from_bech32m_string()` for that. pub fn is_platform_address_string(s: &str) -> bool { - let s = s.to_lowercase(); for hrp in [PLATFORM_HRP_MAINNET, PLATFORM_HRP_TESTNET] { - if s.starts_with(hrp) && s.get(hrp.len()..hrp.len() + 1) == Some("1") { + if s.len() > hrp.len() + && s[..hrp.len()].eq_ignore_ascii_case(hrp) + && s.as_bytes()[hrp.len()] == b'1' + { return true; } } From 631aa1aef0e554d585c757c8d6f8fe3645552de4 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:17:08 +0100 Subject: [PATCH 121/147] refactor(ui): remove unused label field and is_key_only from AccountSummary The label field was constructed but never read outside the builder. is_key_only() had no callers. Remove both instead of suppressing dead_code warnings. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/account_summary.rs | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/src/ui/wallets/account_summary.rs b/src/ui/wallets/account_summary.rs index 10baf6967..9f1e7edc8 100644 --- a/src/ui/wallets/account_summary.rs +++ b/src/ui/wallets/account_summary.rs @@ -166,23 +166,6 @@ impl AccountCategory { ) } - /// Returns true if this account category is primarily used for key - /// derivation and proofs rather than holding funds. - #[allow(dead_code)] - pub fn is_key_only(&self) -> bool { - matches!( - self, - AccountCategory::IdentityRegistration - | AccountCategory::IdentityTopup - | AccountCategory::IdentityInvitation - | AccountCategory::IdentitySystem - | AccountCategory::ProviderVoting - | AccountCategory::ProviderOwner - | AccountCategory::ProviderOperator - | AccountCategory::ProviderPlatform - ) - } - /// Returns true if this is a "system" account category shown only in /// developer mode under the consolidated System tab. pub fn is_system_category(&self) -> bool { @@ -216,8 +199,6 @@ pub(crate) fn categorize_account_path( #[derive(Clone, Debug)] pub struct AccountSummary { pub category: AccountCategory, - #[allow(dead_code)] - pub label: String, pub index: Option, pub confirmed_balance: u64, /// Platform credits balance for Platform Payment addresses @@ -251,11 +232,8 @@ impl AccountSummaryBuilder { } fn build(self) -> AccountSummary { - let label = self.key.category.label(self.key.index); - AccountSummary { category: self.key.category, - label, index: self.key.index, confirmed_balance: self.confirmed_balance, platform_credits: self.platform_credits, From b4fe404060f2d635dd9ea734f979567b536e5f2a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:18:12 +0100 Subject: [PATCH 122/147] refactor(ui): avoid cloning full AccountTab enum in tab content match Extract only the category data (clone of AccountCategory + index) before the match instead of cloning the entire enum. This avoids unnecessary allocation for the Shielded and System variants. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/wallets_screen/mod.rs | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 2d6d59c77..c1dd1e1af 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -1343,9 +1343,13 @@ impl WalletsBalancesScreen { ui.separator(); ui.add_space(4.0); - // Tab content - match &self.selected_account_tab.clone() { - AccountTab::Shielded => { + // Tab content — extract category data to avoid cloning the whole enum + let tab_category = match &self.selected_account_tab { + AccountTab::Category(cat, idx) => Some((cat.clone(), *idx)), + _ => None, + }; + match (&self.selected_account_tab, tab_category) { + (AccountTab::Shielded, _) => { let seed_hash = self .selected_wallet .as_ref() @@ -1359,14 +1363,14 @@ impl WalletsBalancesScreen { action |= shielded_view.ui(ui); } } - AccountTab::System => { + (AccountTab::System, _) => { action |= self.render_system_tab_content(ui, summaries); } - AccountTab::Category(cat, idx) => { + (AccountTab::Category(..), Some((cat, idx))) => { // Show empty state if no summaries match this category if !summaries .iter() - .any(|s| s.category == *cat && s.index == *idx) + .any(|s| s.category == cat && s.index == idx) && !matches!(cat, AccountCategory::Bip44) { ui.label( @@ -1387,16 +1391,16 @@ impl WalletsBalancesScreen { ui.add_space(4.0); } - self.selected_account = Some((cat.clone(), *idx)); + self.selected_account = Some((cat.clone(), idx)); // Addresses (collapsible) - let addresses_heading = format!("Addresses ({})", cat.label(*idx)); + let addresses_heading = format!("Addresses ({})", cat.label(idx)); let addr_header = egui::CollapsingHeader::new( RichText::new(addresses_heading) .size(16.0) .color(DashColors::text_primary(dark_mode)), ) - .id_salt(format!("addresses_{}_{:?}", cat.tab_label(*idx), idx)) + .id_salt(format!("addresses_{}_{:?}", cat.tab_label(idx), idx)) .default_open(true); addr_header.show(ui, |ui| { ui.horizontal(|ui| { @@ -1413,7 +1417,7 @@ impl WalletsBalancesScreen { }); // Dash Core tab: transaction history + asset locks - if *cat == AccountCategory::Bip44 && *idx == Some(0) { + if cat == AccountCategory::Bip44 && idx == Some(0) { // Transaction History (collapsible) ui.add_space(10.0); let tx_header = egui::CollapsingHeader::new( @@ -1441,6 +1445,7 @@ impl WalletsBalancesScreen { }); } } + _ => {} } action From ceb96af7fcd3dcaabbffae10ff72eea05e1d1a1b Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:18:28 +0100 Subject: [PATCH 123/147] fix(ui): clear validated_address on network switch in mine dialog invalidate_address_inputs() cleared the AddressInput widget but left the validated_address stale, which could reference an address from the previous network. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/wallets_screen/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index c1dd1e1af..59495f4b5 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -467,6 +467,7 @@ impl WalletsBalancesScreen { /// Reset all cached AddressInput widgets so they pick up the new network. pub(crate) fn invalidate_address_inputs(&mut self) { self.mine_dialog.address_input = None; + self.mine_dialog.validated_address = None; self.cached_tx_indices = None; } From d595fb58169315f9d27ec858ed3adf46426e3bf7 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:22:09 +0100 Subject: [PATCH 124/147] refactor(model): move truncate_address to model layer and document ASCII precondition Move truncate_address from address_input.rs and shielded_tab.rs into src/model/address.rs as a parameterized public function. Both callers now delegate to the shared implementation with their own prefix/suffix lengths. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/model/address.rs | 14 ++++++++++++++ src/ui/components/address_input.rs | 16 ++-------------- src/ui/wallets/shielded_tab.rs | 7 ++----- 3 files changed, 18 insertions(+), 19 deletions(-) diff --git a/src/model/address.rs b/src/model/address.rs index a99f8c6b6..96d70a282 100644 --- a/src/model/address.rs +++ b/src/model/address.rs @@ -233,6 +233,20 @@ impl std::fmt::Display for ValidatedAddress { } } +/// Truncate an address string for display, showing a prefix and suffix +/// separated by an ellipsis. +/// +/// Assumes ASCII input (Base58 and Bech32/Bech32m addresses are always ASCII). +/// Addresses shorter than `prefix_len + suffix_len + 3` characters are returned +/// unchanged (truncation would not save space). +pub fn truncate_address(addr: &str, prefix_len: usize, suffix_len: usize) -> String { + let min_useful = prefix_len + suffix_len + 3; // 3 for "..." + if addr.len() < min_useful { + return addr.to_string(); + } + format!("{}...{}", &addr[..prefix_len], &addr[addr.len() - suffix_len..]) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/ui/components/address_input.rs b/src/ui/components/address_input.rs index 654f3d5c0..1bf752669 100644 --- a/src/ui/components/address_input.rs +++ b/src/ui/components/address_input.rs @@ -1047,21 +1047,9 @@ fn detect_address_type(input: &str, identity_enabled: bool) -> DetectedType { } } -/// Truncate an address string for display, showing prefix and suffix. +/// Truncate an address for display in the address input component (8 prefix + 6 suffix). fn truncate_address(addr: &str) -> String { - if addr.chars().count() <= 16 { - return addr.to_string(); - } - let prefix: String = addr.chars().take(8).collect(); - let suffix: String = addr - .chars() - .rev() - .take(6) - .collect::() - .chars() - .rev() - .collect(); - format!("{prefix}...{suffix}") + crate::model::address::truncate_address(addr, 8, 6) } #[cfg(test)] diff --git a/src/ui/wallets/shielded_tab.rs b/src/ui/wallets/shielded_tab.rs index e72f066e8..db916a140 100644 --- a/src/ui/wallets/shielded_tab.rs +++ b/src/ui/wallets/shielded_tab.rs @@ -631,12 +631,9 @@ impl ShieldedTabView { } } -/// Truncate a bech32m address for display: first 12 chars + `...` + last 8 chars. +/// Truncate a bech32m address for display (12 prefix + 8 suffix). fn truncate_address(addr: &str) -> String { - if addr.len() <= 23 { - return addr.to_string(); - } - format!("{}...{}", &addr[..12], &addr[addr.len() - 8..]) + crate::model::address::truncate_address(addr, 12, 8) } fn format_credits(credits: u64) -> String { From d9d6ac96ace61e63e4e8178a70ec61034370e62a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:23:42 +0100 Subject: [PATCH 125/147] docs(shielded): document why spawn_blocking trampoline is needed in queue_shielded_sync The spawn_blocking(block_on(...)) pattern is required because async methods on Arc produce non-'static futures due to a known Rust compiler limitation (rust-lang/rust#100013). Document this to prevent future removal attempts. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/context/wallet_lifecycle.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index 3f1d3a597..97c15209d 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -188,6 +188,12 @@ impl AppContext { /// Queue async SyncNotes -> CheckNullifiers for an already-initialized /// shielded wallet. Tracked via `subtasks` so it participates in graceful /// shutdown and cancellation. + /// + /// Uses `spawn_blocking(block_on(...))` because the async methods on + /// `Arc` produce futures that borrow `self`, which the compiler + /// cannot prove are `'static` (rust-lang/rust#100013). The trampoline + /// resolves the futures synchronously on a blocking thread, satisfying + /// the `'static` bound required by `spawn_sync`. fn queue_shielded_sync(self: &Arc, seed_hash: WalletSeedHash) { let ctx = Arc::clone(self); self.subtasks.spawn_sync("shielded_sync", async move { From 8bab96bb9547db781f7ce1ea8d5d3d4977b0b2ba Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:24:14 +0100 Subject: [PATCH 126/147] style: apply nightly rustfmt formatting Co-Authored-By: Claude Opus 4.6 (1M context) --- src/model/address.rs | 6 +++++- tests/backend-e2e/tx_is_ours.rs | 12 ++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/model/address.rs b/src/model/address.rs index 96d70a282..9f6d7bbc7 100644 --- a/src/model/address.rs +++ b/src/model/address.rs @@ -244,7 +244,11 @@ pub fn truncate_address(addr: &str, prefix_len: usize, suffix_len: usize) -> Str if addr.len() < min_useful { return addr.to_string(); } - format!("{}...{}", &addr[..prefix_len], &addr[addr.len() - suffix_len..]) + format!( + "{}...{}", + &addr[..prefix_len], + &addr[addr.len() - suffix_len..] + ) } #[cfg(test)] diff --git a/tests/backend-e2e/tx_is_ours.rs b/tests/backend-e2e/tx_is_ours.rs index 2f548f042..1281b69d2 100644 --- a/tests/backend-e2e/tx_is_ours.rs +++ b/tests/backend-e2e/tx_is_ours.rs @@ -88,7 +88,11 @@ async fn test_spv_transactions_is_ours_flag() { // Check is_ours on wallet A (sender) — should be true { let wallets = app_context.wallets().read().expect("wallets lock"); - let wallet = wallets.get(&hash_a).expect("wallet A").read().expect("lock"); + let wallet = wallets + .get(&hash_a) + .expect("wallet A") + .read() + .expect("lock"); let tx = wallet .transactions .iter() @@ -107,7 +111,11 @@ async fn test_spv_transactions_is_ours_flag() { // Check is_ours on wallet B (receiver) — should be true { let wallets = app_context.wallets().read().expect("wallets lock"); - let wallet = wallets.get(&hash_b).expect("wallet B").read().expect("lock"); + let wallet = wallets + .get(&hash_b) + .expect("wallet B") + .read() + .expect("lock"); let tx = wallet .transactions .iter() From 8b7388596c694bc53448747743e7bc3eb29045aa Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:41:26 +0100 Subject: [PATCH 127/147] fix(wallet): bootstrap platform addresses on wallet creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bootstrap_wallet_addresses only ran when known_addresses was empty, but new_from_seed already derives one Core address. This meant platform payment addresses were never bootstrapped for new wallets — only populated later by network sync (which returns nothing for fresh wallets with no on-chain activity). Now checks for the presence of PlatformPayment addresses in watched_addresses and runs bootstrap if missing. Bootstrap functions are idempotent (add_address_if_not_exists). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/context/wallet_lifecycle.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index 97c15209d..54b2b29e3 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -137,11 +137,17 @@ impl AppContext { } pub fn bootstrap_wallet_addresses(&self, wallet: &Arc>) { - if let Ok(mut guard) = wallet.write() - && guard.known_addresses.is_empty() - { - tracing::info!(wallet = %hex::encode(guard.seed_hash()), "Bootstrapping wallet addresses"); - guard.bootstrap_known_addresses(self); + if let Ok(mut guard) = wallet.write() { + // Bootstrap when no addresses exist (fresh wallet) or when + // platform payment addresses haven't been derived yet (wallet + // created with only a Core address via new_from_seed). + let has_platform_addresses = guard.watched_addresses.values().any(|info| { + info.path_reference == crate::model::wallet::DerivationPathReference::PlatformPayment + }); + if guard.known_addresses.is_empty() || !has_platform_addresses { + tracing::info!(wallet = %hex::encode(guard.seed_hash()), "Bootstrapping wallet addresses"); + guard.bootstrap_known_addresses(self); + } } } From 4d4ee72a05d386eaddfa3e053ced2acc815806b6 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:45:40 +0100 Subject: [PATCH 128/147] fix(ui): show bootstrapped platform addresses in AddressInput Platform addresses were only populated from platform_address_info (network sync results). Fresh wallets with no on-chain activity had empty platform_address_info, so no platform addresses appeared in the Send screen autocomplete. Now derives platform addresses from watched_addresses (which contains all bootstrapped platform payment addresses), with balance looked up from platform_address_info (defaulting to 0). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/components/address_input.rs | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/ui/components/address_input.rs b/src/ui/components/address_input.rs index 1bf752669..489d8b6b9 100644 --- a/src/ui/components/address_input.rs +++ b/src/ui/components/address_input.rs @@ -371,10 +371,27 @@ impl AddressInput { }); } - // Platform addresses from platform_address_info - for (core_addr, info) in &guard.platform_address_info { + // Platform addresses: derive from watched_addresses (all bootstrapped + // platform payment addresses), with balance from platform_address_info. + // This ensures fresh wallets with no on-chain activity still show + // their derived platform addresses. + use crate::model::wallet::DerivationPathReference; + let mut seen_platform = std::collections::HashSet::new(); + for (_path, addr_info) in &guard.watched_addresses { + if addr_info.path_reference != DerivationPathReference::PlatformPayment { + continue; + } + let core_addr = &addr_info.address; if let Ok(platform_addr) = PlatformAddress::try_from(core_addr.clone()) { let addr_str = platform_addr.to_bech32m_string(self.network); + if !seen_platform.insert(addr_str.clone()) { + continue; + } + let balance = guard + .platform_address_info + .get(core_addr) + .map(|info| info.balance) + .unwrap_or(0); let display = if self.full_addresses { format!("{}{}", prefix, addr_str) } else { @@ -385,7 +402,7 @@ impl AddressInput { address_string: addr_str, address_kind: AddressKind::Platform, display_label: display, - balance: info.balance, + balance, validated: ValidatedAddress::Platform { address: platform_addr, bech32m, From 1d910825402f0e9add03e3cec5f6d49298c2fd6e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:41:07 +0100 Subject: [PATCH 129/147] refactor(ui): pass account filter directly to render_address_table Remove the `selected_account` field that was mutated as a side-effect during rendering, causing the last-rendered collapsible section in the System tab to overwrite previous sections' state. Now each render call receives its `(AccountCategory, Option)` as a direct parameter. Additionally, `system_tab_sections` now builds per-(category, index) pair so multi-index accounts show correct per-index balance and address counts instead of aggregating across all indices. Fixes CODE-005, CODE-011, PROJ-004. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../wallets/wallets_screen/address_table.rs | 17 +-- src/ui/wallets/wallets_screen/mod.rs | 109 +++++++++--------- 2 files changed, 62 insertions(+), 64 deletions(-) diff --git a/src/ui/wallets/wallets_screen/address_table.rs b/src/ui/wallets/wallets_screen/address_table.rs index ddcada425..af4348bbf 100644 --- a/src/ui/wallets/wallets_screen/address_table.rs +++ b/src/ui/wallets/wallets_screen/address_table.rs @@ -101,7 +101,11 @@ impl WalletsBalancesScreen { categorize_account_path(path, network, reference) } - pub(super) fn render_address_table(&mut self, ui: &mut Ui) -> AppAction { + pub(super) fn render_address_table( + &mut self, + ui: &mut Ui, + account_filter: (AccountCategory, Option), + ) -> AppAction { let action = AppAction::None; // Move the data preparation into its own scope @@ -197,9 +201,10 @@ impl WalletsBalancesScreen { // Sort the data self.sort_address_data(&mut address_data); - if let Some((category, index)) = self.selected_account.clone() { + { + let (ref category, ref index) = account_filter; address_data - .retain(|data| data.account_category == category && data.account_index == index); + .retain(|data| data.account_category == *category && data.account_index == *index); } let account_address_count = address_data.len(); @@ -236,11 +241,7 @@ impl WalletsBalancesScreen { // Space allocation for UI elements is handled by the layout system - let is_platform_account = self - .selected_account - .as_ref() - .map(|(cat, _)| *cat == AccountCategory::PlatformPayment) - .unwrap_or(false); + let is_platform_account = account_filter.0 == AccountCategory::PlatformPayment; // Reset sort column if it refers to a column not visible for the current account type if is_platform_account diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 59495f4b5..d1e031f80 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -118,7 +118,6 @@ pub struct WalletsBalancesScreen { fund_platform_dialog: FundPlatformAddressDialogState, private_key_dialog: PrivateKeyDialogState, mine_dialog: MineDialogState, - selected_account: Option<(AccountCategory, Option)>, show_zero_balance_addresses: bool, /// Pending refresh of platform address balances (triggered after transfers) pending_platform_balance_refresh: Option, @@ -247,7 +246,6 @@ impl WalletsBalancesScreen { fund_platform_dialog: FundPlatformAddressDialogState::default(), private_key_dialog: PrivateKeyDialogState::default(), mine_dialog: MineDialogState::default(), - selected_account: None, show_zero_balance_addresses: false, pending_platform_balance_refresh: None, pending_refresh_after_unlock: false, @@ -365,7 +363,7 @@ impl WalletsBalancesScreen { .and_then(|w| w.read().ok().map(|g| g.seed_hash())); self.selected_wallet = wallet; self.selected_single_key_wallet = None; - self.selected_account = None; + self.selected_account_tab = AccountTab::default(); self.cached_tx_indices = None; @@ -393,7 +391,7 @@ impl WalletsBalancesScreen { fn select_single_key_wallet(&mut self, wallet: Arc>) { self.selected_single_key_wallet = Some(wallet.clone()); self.selected_wallet = None; - self.selected_account = None; + self.platform_sync_info = None; self.utxo_page = 0; @@ -411,7 +409,7 @@ impl WalletsBalancesScreen { && let Ok(wallets) = self.app_context.wallets.read() && wallets.contains_key(&hash) { - self.selected_account = None; + return; } // HD wallet no longer valid @@ -425,7 +423,7 @@ impl WalletsBalancesScreen { && let Ok(wallets) = self.app_context.single_key_wallets.read() && wallets.contains_key(&hash) { - self.selected_account = None; + return; } // Single key wallet no longer valid @@ -449,12 +447,12 @@ impl WalletsBalancesScreen { { self.selected_single_key_wallet = Some(wallet); self.selected_wallet = None; - self.selected_account = None; + self.platform_sync_info = None; return; } - self.selected_account = None; + self.platform_sync_info = None; } @@ -757,19 +755,19 @@ impl WalletsBalancesScreen { action } - fn render_bottom_options(&mut self, ui: &mut Ui) { + fn render_bottom_options( + &mut self, + ui: &mut Ui, + account_filter: &(AccountCategory, Option), + ) { let wallet_is_open = self .selected_wallet .as_ref() .is_some_and(|wallet_guard| wallet_guard.read().unwrap().is_open()); // Only show "Add Receiving Address" button for Dash Core account (BIP44 account 0) - let is_main_account = self - .selected_account - .as_ref() - .is_some_and(|(category, index)| { - *category == AccountCategory::Bip44 && *index == Some(0) - }); + let is_main_account = + account_filter.0 == AccountCategory::Bip44 && account_filter.1 == Some(0); if wallet_is_open && is_main_account { ui.add_space(10.0); @@ -1179,11 +1177,12 @@ impl WalletsBalancesScreen { /// Collect the system account categories to display inside the System tab. /// Returns `(category, index, address_count, balance_duffs)` tuples in a /// fixed display order (identity categories first, then provider, then legacy). + /// Each `(category, index)` pair gets its own section with accurate counts. fn system_tab_sections( &self, summaries: &[AccountSummary], ) -> Vec<(AccountCategory, Option, usize, u64)> { - let all_system_categories = [ + let category_order: &[AccountCategory] = &[ AccountCategory::IdentityRegistration, AccountCategory::IdentitySystem, AccountCategory::IdentityTopup, @@ -1196,26 +1195,40 @@ impl WalletsBalancesScreen { AccountCategory::Bip32, ]; - // Precompute per-category address counts in a single pass over - // watched_addresses to avoid O(num_categories * num_addresses) - // per frame. + // Precompute per-(category, index) address counts in a single pass. let address_counts = self.precompute_address_counts(); let mut sections = Vec::new(); - for cat in &all_system_categories { + + // For each category, emit one section per distinct index found in + // summaries. Categories with no summary entries get a single section + // with index from the first matching summary (or None). + for cat in category_order { let matching: Vec<_> = summaries.iter().filter(|s| &s.category == cat).collect(); - let address_count = address_counts.get(cat).copied().unwrap_or(0); - let balance: u64 = matching.iter().map(|s| s.confirmed_balance).sum(); - let idx = matching.first().and_then(|s| s.index); - sections.push((cat.clone(), idx, address_count, balance)); + if matching.is_empty() { + let address_count = address_counts + .get(&(cat.clone(), None)) + .copied() + .unwrap_or(0); + sections.push((cat.clone(), None, address_count, 0u64)); + } else { + for summary in &matching { + let key = (cat.clone(), summary.index); + let address_count = address_counts.get(&key).copied().unwrap_or(0); + sections.push((cat.clone(), summary.index, address_count, summary.confirmed_balance)); + } + } } // Also include any Other(...) categories from summaries for summary in summaries { if matches!(summary.category, AccountCategory::Other(_)) - && !sections.iter().any(|(c, _, _, _)| *c == summary.category) + && !sections + .iter() + .any(|(c, idx, _, _)| *c == summary.category && *idx == summary.index) { - let address_count = address_counts.get(&summary.category).copied().unwrap_or(0); + let key = (summary.category.clone(), summary.index); + let address_count = address_counts.get(&key).copied().unwrap_or(0); sections.push(( summary.category.clone(), summary.index, @@ -1228,10 +1241,12 @@ impl WalletsBalancesScreen { sections } - /// Build a per-category address count map in a single pass over + /// Build a per-(category, index) address count map in a single pass over /// `watched_addresses`. Used by `system_tab_sections` to avoid /// O(num_categories * num_addresses) per frame. - fn precompute_address_counts(&self) -> std::collections::HashMap { + fn precompute_address_counts( + &self, + ) -> std::collections::HashMap<(AccountCategory, Option), usize> { let mut counts = std::collections::HashMap::new(); let Some(wallet_arc) = self.selected_wallet.as_ref() else { return counts; @@ -1241,12 +1256,12 @@ impl WalletsBalancesScreen { }; let network = self.app_context.network; for (path, info) in &wallet.watched_addresses { - let (cat, _) = crate::ui::wallets::account_summary::categorize_account_path( + let (cat, idx) = crate::ui::wallets::account_summary::categorize_account_path( path, network, info.path_reference, ); - *counts.entry(cat).or_insert(0) += 1; + *counts.entry((cat, idx)).or_insert(0) += 1; } counts } @@ -1334,10 +1349,6 @@ impl WalletsBalancesScreen { ui.add_space(4.0); if ui.selectable_label(is_selected, text).clicked() { self.selected_account_tab = tab.clone(); - // Sync the selected_account for address_table filtering - if let AccountTab::Category(cat, idx) = tab { - self.selected_account = Some((cat.clone(), *idx)); - } } } }); @@ -1392,7 +1403,7 @@ impl WalletsBalancesScreen { ui.add_space(4.0); } - self.selected_account = Some((cat.clone(), idx)); + let account_filter = (cat.clone(), idx); // Addresses (collapsible) let addresses_heading = format!("Addresses ({})", cat.label(idx)); @@ -1413,8 +1424,8 @@ impl WalletsBalancesScreen { }); }); ui.add_space(4.0); - action |= self.render_address_table(ui); - self.render_bottom_options(ui); + action |= self.render_address_table(ui, account_filter.clone()); + self.render_bottom_options(ui, &account_filter); }); // Dash Core tab: transaction history + asset locks @@ -1493,8 +1504,7 @@ impl WalletsBalancesScreen { ui.add_space(4.0); } - self.selected_account = Some((cat.clone(), *idx)); - action |= self.render_address_table(ui); + action |= self.render_address_table(ui, (cat.clone(), *idx)); }); ui.add_space(2.0); } @@ -2040,23 +2050,10 @@ impl WalletsBalancesScreen { action } - fn ensure_account_selection(&mut self, summaries: &[AccountSummary]) { - if summaries.is_empty() { - self.selected_account = None; - return; - } - - if let Some((cat, idx)) = &self.selected_account - && summaries - .iter() - .any(|summary| &summary.category == cat && summary.index == *idx) - { - return; - } - - if let Some(first) = summaries.first() { - self.selected_account = Some((first.category.clone(), first.index)); - } + fn ensure_account_selection(&mut self, _summaries: &[AccountSummary]) { + // The tab bar in `render_account_tabs` already validates + // `selected_account_tab` against the built tab list and resets it + // to the first tab if invalid. Nothing extra needed here. } fn lock_selected_wallet(&mut self) { From f3ca53890d43be5651132d272bb66f318b3c1fd9 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:42:59 +0100 Subject: [PATCH 130/147] perf(shielded): move wallet initialization to background thread ZIP32 key derivation and DB reads in `initialize_shielded_wallet` ran synchronously on the UI thread during `handle_wallet_unlocked()`. Move the call to a `spawn_blocking` task tracked by `subtasks` so the UI remains responsive. The shielded tab already handles the "initializing" state gracefully via its `is_initialized` check. Fixes CODE-010. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/context/wallet_lifecycle.rs | 119 +++++++++++++++++--------------- 1 file changed, 64 insertions(+), 55 deletions(-) diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index 54b2b29e3..1b6d16e20 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -157,26 +157,12 @@ 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!( - 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), - error = %e, - "Shielded wallet init skipped on unlock" - ), - } + // Initialize shielded wallet on a background thread to avoid + // blocking the UI — ZIP32 key derivation and DB reads can stall. + // After init completes, queue async SyncNotes -> CheckNullifiers. + // This is the single init path — the UI never dispatches + // InitializeShieldedWallet. + self.queue_shielded_init_and_sync(seed_hash); } } @@ -191,50 +177,73 @@ impl AppContext { self.queue_spv_wallet_unload(seed_hash); } - /// Queue async SyncNotes -> CheckNullifiers for an already-initialized - /// shielded wallet. Tracked via `subtasks` so it participates in graceful - /// shutdown and cancellation. - /// - /// Uses `spawn_blocking(block_on(...))` because the async methods on - /// `Arc` produce futures that borrow `self`, which the compiler - /// cannot prove are `'static` (rust-lang/rust#100013). The trampoline - /// resolves the futures synchronously on a blocking thread, satisfying - /// the `'static` bound required by `spawn_sync`. - fn queue_shielded_sync(self: &Arc, seed_hash: WalletSeedHash) { + /// Queue shielded wallet initialization on a blocking thread, then + /// follow up with note sync + nullifier check. Tracked via `subtasks` + /// so it participates in graceful shutdown and cancellation. + fn queue_shielded_init_and_sync(self: &Arc, seed_hash: WalletSeedHash) { let ctx = Arc::clone(self); - self.subtasks.spawn_sync("shielded_sync", async move { - let handle = tokio::runtime::Handle::current(); - let result = tokio::task::spawn_blocking(move || { - handle.block_on(async { - match ctx.sync_shielded_notes(seed_hash).await { - Ok(_) => { - if let Err(e) = ctx.check_nullifiers_task(seed_hash).await { - tracing::debug!( - seed = %hex::encode(seed_hash), - error = %e, - "Shielded nullifier check after init failed" - ); - } - } - Err(e) => tracing::debug!( - seed = %hex::encode(seed_hash), - error = %e, - "Shielded note sync after init failed" - ), - } - }) + self.subtasks.spawn_sync("shielded_init", async move { + let ctx2 = Arc::clone(&ctx); + let init_result = tokio::task::spawn_blocking(move || { + ctx2.initialize_shielded_wallet(seed_hash) }) .await; - if let Err(e) = result { - tracing::debug!( + match init_result { + Ok(Ok(_)) => { + tracing::trace!( + seed = %hex::encode(seed_hash), + "Shielded wallet state initialized on unlock" + ); + ctx.run_shielded_sync(seed_hash).await; + } + Ok(Err(e)) => tracing::debug!( seed = %hex::encode(seed_hash), error = %e, - "Shielded sync task panicked" - ); + "Shielded wallet init skipped on unlock" + ), + Err(e) => tracing::debug!( + seed = %hex::encode(seed_hash), + error = %e, + "Shielded init task panicked" + ), } }); } + /// Run SyncNotes -> CheckNullifiers sequence on a blocking thread. + async fn run_shielded_sync(self: &Arc, seed_hash: WalletSeedHash) { + let ctx = Arc::clone(self); + let handle = tokio::runtime::Handle::current(); + let result = tokio::task::spawn_blocking(move || { + handle.block_on(async { + match ctx.sync_shielded_notes(seed_hash).await { + Ok(_) => { + if let Err(e) = ctx.check_nullifiers_task(seed_hash).await { + tracing::debug!( + seed = %hex::encode(seed_hash), + error = %e, + "Shielded nullifier check after init failed" + ); + } + } + Err(e) => tracing::debug!( + seed = %hex::encode(seed_hash), + error = %e, + "Shielded note sync after init failed" + ), + } + }) + }) + .await; + if let Err(e) = result { + tracing::debug!( + seed = %hex::encode(seed_hash), + error = %e, + "Shielded sync task panicked" + ); + } + } + fn wallet_seed_snapshot(wallet: &Arc>) -> Option<(WalletSeedHash, [u8; 64])> { let guard = wallet.read().ok()?; if !guard.is_open() { From 261fe9eaa4fc755e5c0635af1a68dc70e50ff23b Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:45:23 +0100 Subject: [PATCH 131/147] test(spv): add unit tests for is_ours override logic Extract the SPV is_ours override into a named function and add four unit tests covering: outgoing already-ours, incoming not-ours (the main override case), zero-amount edge case, and outgoing not-ours. A comment explains why bloom filter false positive testing requires mocking the SPV layer and is out of scope for unit tests. Fixes PROJ-002. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/context/wallet_lifecycle.rs | 62 ++++++++++++++++++++++++++------- 1 file changed, 49 insertions(+), 13 deletions(-) diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index 1b6d16e20..1f2305da9 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -921,19 +921,7 @@ impl AppContext { net_amount: record.net_amount, fee: record.fee, label: record.label.clone(), - // SPV transaction history is per-wallet — all entries - // involve our addresses. Upstream sets is_ours only for - // sends (net_amount < 0); we override to true for all. - is_ours: { - if !record.is_ours && record.net_amount >= 0 { - tracing::debug!( - txid = %record.txid, - net_amount = record.net_amount, - "SPV: overriding is_ours to true for receive transaction" - ); - } - true - }, + is_ours: spv_is_ours_override(record.is_ours, record.net_amount), status, } }) @@ -981,3 +969,51 @@ impl AppContext { self.connection_status.reset_timer(); } } + +/// SPV transaction history is per-wallet — all entries involve our addresses +/// (they passed bloom filter + `check_transaction()` address matching). +/// Upstream sets `is_ours` only for sends (`net_amount < 0`); we override +/// to `true` for all matched transactions since address ownership was +/// already verified by the SPV layer. +/// +/// Bloom filter false positives are filtered by `check_transaction()` before +/// records reach this point, so the override is safe. Testing actual bloom +/// filter FP behavior would require mocking the SPV layer's bloom filter, +/// which is out of scope for unit tests. +fn spv_is_ours_override(upstream_is_ours: bool, net_amount: i64) -> bool { + if !upstream_is_ours && net_amount >= 0 { + tracing::debug!( + net_amount, + "SPV: overriding is_ours to true for receive transaction" + ); + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_ours_override_true_for_outgoing_already_ours() { + assert!(spv_is_ours_override(true, -50_000)); + } + + #[test] + fn is_ours_override_true_for_incoming_not_ours() { + // Upstream marks receive transactions as !is_ours — we override. + assert!(spv_is_ours_override(false, 100_000)); + } + + #[test] + fn is_ours_override_true_for_zero_amount_not_ours() { + // Edge case: net_amount == 0 (e.g. self-transfer minus fee) + assert!(spv_is_ours_override(false, 0)); + } + + #[test] + fn is_ours_override_true_for_outgoing_not_ours() { + // Even if upstream says !is_ours for a send, we override. + assert!(spv_is_ours_override(false, -10_000)); + } +} From 0b323c1be51acf5958fa86fbe7d41d4db01ee26e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:45:50 +0100 Subject: [PATCH 132/147] docs(user-stories): add stories for wallet tab redesign Add WAL-021 through WAL-024 covering the tab-based navigation, developer-mode System tab, collapsible transaction history, and collapsible balance breakdown sections introduced in PR #791. Fixes PROJ-007. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/user-stories.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/docs/user-stories.md b/docs/user-stories.md index aaf975c30..52ee64cdd 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -186,6 +186,43 @@ As a user, I want to withdraw credits from a Platform address back to a Core add - Destination Core address input. - Fee strategy configuration. +### WAL-021: Navigate wallet accounts via tabs [Implemented] +**Persona:** Alex, Priya + +As a user, I want to see clear tabs for Dash Core, Platform, and Shielded so that I can switch between account views without searching through a dropdown. + +- Tab bar replaces account category dropdown. +- Each tab shows its balance in the label. +- Empty accounts display "(empty)" indicator. +- Switching tabs is instant with no data reload. + +### WAL-022: View system accounts in developer mode [Implemented] +**Persona:** Jordan + +As a developer, I want a System tab that reveals all internal account categories (Identity Registration, CoinJoin, Provider keys, etc.) so that I can inspect low-level wallet structure without cluttering the default view. + +- System tab appears only when developer mode is enabled. +- Each system account category is shown as a collapsible section. +- Section headers display address count and balance. + +### WAL-023: Collapsible transaction history [Implemented] +**Persona:** Alex, Priya + +As a user, I want the transaction history to be collapsible so that I can focus on addresses or balances without scrolling past a long list of transactions. + +- Transaction history section has a collapsible header. +- Collapsed by default to reduce visual clutter. +- Expand/collapse state persists within the session. + +### WAL-024: Collapsible balance breakdown [Implemented] +**Persona:** Priya + +As a power user, I want the balance breakdown and address table to be collapsible so that I can focus on the information I need at the moment. + +- Address table section has a collapsible header. +- Asset locks section has a collapsible header. +- Sections are expanded by default for quick access. + --- ## Send and Receive (SND) From 11578bdd7ce4c9702be30ef26c1962b661bcf01c Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:46:29 +0100 Subject: [PATCH 133/147] style: apply nightly rustfmt formatting Co-Authored-By: Claude Opus 4.6 (1M context) --- src/context/wallet_lifecycle.rs | 7 +++---- src/ui/wallets/wallets_screen/mod.rs | 12 +++++++----- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index 1f2305da9..ae7e5fa2c 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -184,10 +184,9 @@ impl AppContext { let ctx = Arc::clone(self); self.subtasks.spawn_sync("shielded_init", async move { let ctx2 = Arc::clone(&ctx); - let init_result = tokio::task::spawn_blocking(move || { - ctx2.initialize_shielded_wallet(seed_hash) - }) - .await; + let init_result = + tokio::task::spawn_blocking(move || ctx2.initialize_shielded_wallet(seed_hash)) + .await; match init_result { Ok(Ok(_)) => { tracing::trace!( diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index d1e031f80..9e9804b2b 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -409,7 +409,6 @@ impl WalletsBalancesScreen { && let Ok(wallets) = self.app_context.wallets.read() && wallets.contains_key(&hash) { - return; } // HD wallet no longer valid @@ -423,7 +422,6 @@ impl WalletsBalancesScreen { && let Ok(wallets) = self.app_context.single_key_wallets.read() && wallets.contains_key(&hash) { - return; } // Single key wallet no longer valid @@ -447,12 +445,11 @@ impl WalletsBalancesScreen { { self.selected_single_key_wallet = Some(wallet); self.selected_wallet = None; - + self.platform_sync_info = None; return; } - self.platform_sync_info = None; } @@ -1215,7 +1212,12 @@ impl WalletsBalancesScreen { for summary in &matching { let key = (cat.clone(), summary.index); let address_count = address_counts.get(&key).copied().unwrap_or(0); - sections.push((cat.clone(), summary.index, address_count, summary.confirmed_balance)); + sections.push(( + cat.clone(), + summary.index, + address_count, + summary.confirmed_balance, + )); } } } From c096105cf69c576a385f76da52226d6845f1a326 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:57:32 +0100 Subject: [PATCH 134/147] feat(ui): distinguish change addresses in AddressInput autocomplete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core addresses now show "(change)" suffix for BIP44 change addresses (m/44'/5'/0'/1/x). New with_exclude_change(true) builder method filters them out — send inputs should not display change addresses since users don't share them with others. Change detection uses the existing DerivationPathHelpers::is_bip44_change trait method from the wallet model. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/components/address_input.rs | 41 +++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/src/ui/components/address_input.rs b/src/ui/components/address_input.rs index 489d8b6b9..c40ce687f 100644 --- a/src/ui/components/address_input.rs +++ b/src/ui/components/address_input.rs @@ -1,7 +1,7 @@ use crate::model::address::{AddressKind, ValidatedAddress}; use crate::model::amount::{Amount, DASH_DECIMAL_PLACES}; use crate::model::qualified_identity::QualifiedIdentity; -use crate::model::wallet::Wallet; +use crate::model::wallet::{DerivationPathHelpers, Wallet}; use crate::ui::components::{Component, ComponentResponse}; use crate::ui::theme::DashColors; use dash_sdk::dashcore_rpc::dashcore::address::NetworkUnchecked; @@ -51,6 +51,12 @@ struct AddressEntry { balance: u64, /// Pre-built ValidatedAddress for immediate use on selection. validated: ValidatedAddress, + /// Whether this is a change address (BIP44 m/44'/5'/0'/1/x). + /// Only meaningful for Core addresses; always false for other types. + /// Stored for potential future use in display styling; the "(change)" + /// suffix is already baked into `display_label` at construction time. + #[allow(dead_code)] + is_change: bool, } /// Concrete balance range bounds. @@ -152,6 +158,7 @@ pub struct AddressInput { desired_width: Option, show_validation_errors: bool, balance_range: Option, + exclude_change: bool, // --- Autocomplete data (set via builder, read each frame) --- all_entries: Vec, @@ -194,6 +201,7 @@ impl AddressInput { selected_from_autocomplete: false, cached_detection: None, changed: false, + exclude_change: false, } } @@ -246,6 +254,15 @@ impl AddressInput { self } + /// Exclude change addresses (BIP44 m/44'/5'/0'/1/x) from autocomplete. + /// + /// Send inputs should typically exclude change addresses since users + /// don't share change addresses with others. Default: false (show all). + pub fn with_exclude_change(mut self, exclude: bool) -> Self { + self.exclude_change = exclude; + self + } + /// Enable DPNS username resolution for Identity-type addresses. Default: true. pub fn with_dpns_resolution(mut self, enabled: bool) -> Self { self.dpns_resolution = enabled; @@ -354,13 +371,25 @@ impl AddressInput { // Balance is looked up from address_balances; addresses without UTXOs // get balance 0. Use `with_balance_range(1..)` to show only funded // addresses — do NOT filter at the data source. - for address in guard.known_addresses.keys() { + // Change addresses (BIP44 m/44'/5'/0'/1/x) are tagged and can be + // excluded via `with_exclude_change(true)`. + for (address, derivation_path) in &guard.known_addresses { + let is_change = derivation_path.is_bip44_change(self.network); + if self.exclude_change && is_change { + continue; + } let balance = guard.address_balances.get(address).copied().unwrap_or(0); let addr_str = address.to_string(); + let change_suffix = if is_change { " (change)" } else { "" }; let display = if self.full_addresses { - format!("{}{}", prefix, addr_str) + format!("{}{}{}", prefix, addr_str, change_suffix) } else { - format!("{}{}", prefix, truncate_address(&addr_str)) + format!( + "{}{}{}", + prefix, + truncate_address(&addr_str), + change_suffix + ) }; self.all_entries.push(AddressEntry { address_string: addr_str, @@ -368,6 +397,7 @@ impl AddressInput { display_label: display, balance, validated: ValidatedAddress::Core(address.clone()), + is_change, }); } @@ -407,6 +437,7 @@ impl AddressInput { address: platform_addr, bech32m, }, + is_change: false, }); } } @@ -435,6 +466,7 @@ impl AddressInput { id, dpns_name: dpns_name.clone(), }, + is_change: false, }); } } @@ -451,6 +483,7 @@ impl AddressInput { display_label: display, balance, validated: ValidatedAddress::Shielded(address), + is_change: false, }); } From e95a393fe05a618e19ab027e16f056ddbb165557 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:58:18 +0100 Subject: [PATCH 135/147] fix(ui): exclude change addresses from Send screen destination input Change addresses are internal wallet addresses not meant to be shared. The Send screen's destination AddressInput now uses with_exclude_change(true) to filter them out of the autocomplete. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/send_screen.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index 62dc390cc..f259ad195 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -1507,7 +1507,8 @@ impl WalletSendScreen { let mut builder = AddressInput::new(self.app_context.network) .with_label("Send to") .with_hint_text("Enter address (X.../y.../dash1.../tdash1...)") - .with_address_kinds(&allowed_kinds); + .with_address_kinds(&allowed_kinds) + .with_exclude_change(true); // Provide all wallet addresses for autocomplete if let Ok(wallets_guard) = self.app_context.wallets.read() { From b475d1a27b5f0d1e54ad0adb908bb159bd16a6b7 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:02:50 +0100 Subject: [PATCH 136/147] feat(ui): add new address generation button to wallet tabs Add right-aligned compact buttons below the address table for generating new addresses on Core (BIP44) and Platform tabs. Core addresses use the async BackendTask path for SPV compatibility; Platform addresses derive synchronously. The button is disabled during generation to prevent double-clicks. Shielded tab already has its own button; System tab has no button (addresses are derived automatically). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/context/wallet_lifecycle.rs | 3 +- src/ui/components/address_input.rs | 9 +- src/ui/wallets/wallets_screen/mod.rs | 119 ++++++++++++++++++--------- 3 files changed, 84 insertions(+), 47 deletions(-) diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index ae7e5fa2c..10d231c7f 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -142,7 +142,8 @@ impl AppContext { // platform payment addresses haven't been derived yet (wallet // created with only a Core address via new_from_seed). let has_platform_addresses = guard.watched_addresses.values().any(|info| { - info.path_reference == crate::model::wallet::DerivationPathReference::PlatformPayment + info.path_reference + == crate::model::wallet::DerivationPathReference::PlatformPayment }); if guard.known_addresses.is_empty() || !has_platform_addresses { tracing::info!(wallet = %hex::encode(guard.seed_hash()), "Bootstrapping wallet addresses"); diff --git a/src/ui/components/address_input.rs b/src/ui/components/address_input.rs index c40ce687f..032cb061b 100644 --- a/src/ui/components/address_input.rs +++ b/src/ui/components/address_input.rs @@ -384,12 +384,7 @@ impl AddressInput { let display = if self.full_addresses { format!("{}{}{}", prefix, addr_str, change_suffix) } else { - format!( - "{}{}{}", - prefix, - truncate_address(&addr_str), - change_suffix - ) + format!("{}{}{}", prefix, truncate_address(&addr_str), change_suffix) }; self.all_entries.push(AddressEntry { address_string: addr_str, @@ -407,7 +402,7 @@ impl AddressInput { // their derived platform addresses. use crate::model::wallet::DerivationPathReference; let mut seen_platform = std::collections::HashSet::new(); - for (_path, addr_info) in &guard.watched_addresses { + for addr_info in guard.watched_addresses.values() { if addr_info.path_reference != DerivationPathReference::PlatformPayment { continue; } diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 9e9804b2b..9579fe172 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -158,6 +158,8 @@ pub struct WalletsBalancesScreen { /// Cached filtered transaction indices for the currently selected wallet. /// Invalidated (set to None) on wallet switch or transaction updates. cached_tx_indices: Option>, + /// Whether a Core receive address generation is in progress (disables button) + generating_core_address: bool, } impl WalletsBalancesScreen { @@ -266,6 +268,7 @@ impl WalletsBalancesScreen { pending_list_wallet_hash: None, pending_list_is_single_key: false, cached_tx_indices: None, + generating_core_address: false, } } @@ -466,35 +469,6 @@ impl WalletsBalancesScreen { self.cached_tx_indices = None; } - fn add_receiving_address(&mut self) { - if let Some(wallet) = &self.selected_wallet { - let result = { - let mut wallet = wallet.write().unwrap(); - wallet.receive_address(self.app_context.network, true, Some(&self.app_context)) - }; - - match result { - Ok(address) => { - let message = format!("Added new receiving address: {}", address); - MessageBanner::set_global( - self.app_context.egui_ctx(), - &message, - MessageType::Success, - ); - } - Err(e) => { - MessageBanner::set_global(self.app_context.egui_ctx(), &e, MessageType::Error); - } - } - } else { - MessageBanner::set_global( - self.app_context.egui_ctx(), - "No wallet selected", - MessageType::Error, - ); - } - } - fn render_wallet_selection(&mut self, ui: &mut Ui) -> AppAction { let action = AppAction::None; @@ -756,27 +730,86 @@ impl WalletsBalancesScreen { &mut self, ui: &mut Ui, account_filter: &(AccountCategory, Option), - ) { + ) -> AppAction { + let mut action = AppAction::None; + let wallet_is_open = self .selected_wallet .as_ref() .is_some_and(|wallet_guard| wallet_guard.read().unwrap().is_open()); - // Only show "Add Receiving Address" button for Dash Core account (BIP44 account 0) - let is_main_account = - account_filter.0 == AccountCategory::Bip44 && account_filter.1 == Some(0); + if !wallet_is_open { + return action; + } - if wallet_is_open && is_main_account { - ui.add_space(10.0); - ui.horizontal(|ui| { + let is_bip44 = account_filter.0 == AccountCategory::Bip44; + let is_platform = account_filter.0 == AccountCategory::PlatformPayment; + + if is_bip44 { + ui.add_space(8.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + let button = egui::Button::new(RichText::new("+ New Receive Address").size(13.0)) + .min_size(egui::vec2(0.0, 24.0)); if ui - .button(RichText::new("➕ Add Receiving Address").size(14.0)) + .add_enabled(!self.generating_core_address, button) .clicked() + && let Some(wallet) = &self.selected_wallet { - self.add_receiving_address(); + let seed_hash = wallet.read().unwrap().seed_hash(); + self.generating_core_address = true; + action = AppAction::BackendTask(BackendTask::WalletTask( + crate::backend_task::wallet::WalletTask::GenerateReceiveAddress { + seed_hash, + }, + )); + } + }); + } else if is_platform { + ui.add_space(8.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + let button = egui::Button::new(RichText::new("+ New Platform Address").size(13.0)) + .min_size(egui::vec2(0.0, 24.0)); + if ui.add(button).clicked() { + self.add_new_platform_address(); } }); } + + action + } + + fn add_new_platform_address(&mut self) { + if let Some(wallet) = &self.selected_wallet { + let result = { + let mut wallet = wallet.write().unwrap(); + wallet.platform_receive_address( + self.app_context.network, + true, + Some(&self.app_context), + ) + }; + match result { + Ok(address) => { + use dash_sdk::dpp::address_funds::PlatformAddress; + let display = PlatformAddress::try_from(address) + .map(|pa| pa.to_bech32m_string(self.app_context.network)) + .unwrap_or_else(|_| "new address".to_string()); + MessageBanner::set_global( + self.app_context.egui_ctx(), + format!("New Platform address generated: {display}"), + MessageType::Success, + ); + } + Err(e) => { + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Could not generate a new Platform address. Please try again.", + MessageType::Error, + ) + .with_details(e); + } + } + } } fn render_remove_wallet_button(&mut self, ui: &mut Ui) { @@ -1427,7 +1460,7 @@ impl WalletsBalancesScreen { }); ui.add_space(4.0); action |= self.render_address_table(ui, account_filter.clone()); - self.render_bottom_options(ui, &account_filter); + action |= self.render_bottom_options(ui, &account_filter); }); // Dash Core tab: transaction history + asset locks @@ -2679,6 +2712,7 @@ impl ScreenLike for WalletsBalancesScreen { // Banner display is handled globally by AppState; this is only for side-effects. // Always clear refreshing — the originating task is done regardless of result type. self.refreshing = false; + self.generating_core_address = false; if matches!(message_type, MessageType::Error | MessageType::Warning) { self.asset_lock_search_banner.take_and_clear(); @@ -2801,6 +2835,7 @@ impl ScreenLike for WalletsBalancesScreen { MessageBanner::set_global(self.app_context.egui_ctx(), &msg, MessageType::Success); } crate::ui::BackendTaskSuccessResult::GeneratedReceiveAddress { seed_hash, address } => { + self.generating_core_address = false; if let Some(selected) = &self.selected_wallet && let Ok(wallet) = selected.read() && wallet.seed_hash() == seed_hash @@ -2821,6 +2856,12 @@ impl ScreenLike for WalletsBalancesScreen { self.receive_dialog.qr_texture = None; self.receive_dialog.qr_address = None; self.receive_dialog.status = None; + + MessageBanner::set_global( + self.app_context.egui_ctx(), + format!("New receive address generated: {address}"), + MessageType::Success, + ); } } crate::ui::BackendTaskSuccessResult::PlatformAddressWithdrawal { .. } => { From 4889de7c5cfefbc54acaaef9d2aaaaa15b1b5b2f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:04:43 +0100 Subject: [PATCH 137/147] fix(ui): exclude system addresses from AddressInput autocomplete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit System addresses (Identity Registration, CoinJoin, Provider keys, etc.) are internal wallet infrastructure not meant for user-facing send/receive operations. Filter them out by checking each address against watched_addresses path_reference → AccountCategory. Only BIP44 (Core) and PlatformPayment addresses are shown. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/components/address_input.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/ui/components/address_input.rs b/src/ui/components/address_input.rs index 032cb061b..931b215cd 100644 --- a/src/ui/components/address_input.rs +++ b/src/ui/components/address_input.rs @@ -367,13 +367,30 @@ impl AddressInput { String::new() }; + // Build a set of system addresses to exclude from autocomplete. + // System addresses (Identity Registration, CoinJoin, Provider keys, etc.) + // are internal wallet infrastructure — not for user-facing send/receive. + use crate::ui::wallets::account_summary::AccountCategory; + let system_addresses: std::collections::HashSet<&Address> = guard + .watched_addresses + .values() + .filter(|info| { + AccountCategory::from_reference(info.path_reference).is_system_category() + }) + .map(|info| &info.address) + .collect(); + // Core addresses from known_addresses (all derived addresses). // Balance is looked up from address_balances; addresses without UTXOs // get balance 0. Use `with_balance_range(1..)` to show only funded // addresses — do NOT filter at the data source. // Change addresses (BIP44 m/44'/5'/0'/1/x) are tagged and can be // excluded via `with_exclude_change(true)`. + // System addresses are always excluded. for (address, derivation_path) in &guard.known_addresses { + if system_addresses.contains(address) { + continue; + } let is_change = derivation_path.is_bip44_change(self.network); if self.exclude_change && is_change { continue; From f6b7bf7db14151b455e8bebc5b88b182ed3e7d3a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:12:12 +0100 Subject: [PATCH 138/147] feat(ui): always show address type label, enable type-based filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address type suffix (Core/Platform/Shielded/Identity) is now always shown in autocomplete entries, not just when multiple types are enabled. Users can type "platform", "core", "plat", etc. to filter entries by type — the filter matches against both short_label() and display_name(). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/components/address_input.rs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/ui/components/address_input.rs b/src/ui/components/address_input.rs index 931b215cd..71a5faf32 100644 --- a/src/ui/components/address_input.rs +++ b/src/ui/components/address_input.rs @@ -731,9 +731,12 @@ impl AddressInput { if query.is_empty() { return true; } - // Substring match against address and label + // Substring match against address, label, and type name. + // Typing "platform" or "core" filters to that address type. e.address_string.to_lowercase().contains(&query) || e.display_label.to_lowercase().contains(&query) + || e.address_kind.short_label().to_lowercase().contains(&query) + || e.address_kind.display_name().to_lowercase().contains(&query) }) .collect(); @@ -873,15 +876,11 @@ impl AddressInput { // Collect filtered entries into an owned snapshot to release the borrow on self let (filtered, total_entries) = self.filtered_entries(); let filtered_len = filtered.len(); - let show_type_suffix = self.enabled_kinds.len() > 1; let entries_snapshot: Vec<(String, String, AddressEntry)> = filtered .iter() .map(|e| { - let label = if show_type_suffix { - format!("{} ({})", e.display_label, e.address_kind.short_label()) - } else { - e.display_label.clone() - }; + let label = + format!("{} ({})", e.display_label, e.address_kind.short_label()); (label, self.format_balance(e), (*e).clone()) }) .collect(); From d23388ae8969cc0682d80ccc2e70411ef802e7ed Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:16:25 +0100 Subject: [PATCH 139/147] fix(error): add user-friendly message for shielded nonce mismatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detect AddressInvalidNonceError from Platform and show an actionable message: "The transaction used an outdated sequence number. Please retry — the wallet will use the correct number automatically." Previously this fell through to the generic ShieldedBroadcastFailed message which gave no guidance. Upstream fix filed: dashpay/platform#3407 (SDK auto-retry on nonce mismatch). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend_task/error.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index 208323440..8e22bed9b 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -830,6 +830,16 @@ pub enum TaskError { source: Box, }, + /// The nonce used for a shielded transaction was stale. The wallet's cached + /// nonce was behind Platform's expected nonce. Retrying will use the correct nonce. + #[error( + "The transaction used an outdated sequence number. Please retry — the wallet will use the correct number automatically." + )] + ShieldedNonceMismatch { + #[source] + source_error: Box, + }, + /// The address used for a shielded transaction does not have enough locked funds. #[error( "Not enough funds locked for this shielded transaction. \ @@ -1006,6 +1016,13 @@ pub fn shielded_broadcast_error(e: SdkError) -> TaskError { source_error: Box::new(e), }; } + if let Some(ConsensusError::StateError(StateError::AddressInvalidNonceError(_))) = + consensus_error + { + return TaskError::ShieldedNonceMismatch { + source_error: Box::new(e), + }; + } TaskError::ShieldedBroadcastFailed { source: Box::new(e), } From fabcb736122858c72b8f961a339952d1563e1102 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:41:47 +0100 Subject: [PATCH 140/147] style: fix stable rustfmt formatting for address_input filter Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/components/address_input.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ui/components/address_input.rs b/src/ui/components/address_input.rs index 71a5faf32..f4ea72330 100644 --- a/src/ui/components/address_input.rs +++ b/src/ui/components/address_input.rs @@ -736,7 +736,10 @@ impl AddressInput { e.address_string.to_lowercase().contains(&query) || e.display_label.to_lowercase().contains(&query) || e.address_kind.short_label().to_lowercase().contains(&query) - || e.address_kind.display_name().to_lowercase().contains(&query) + || e.address_kind + .display_name() + .to_lowercase() + .contains(&query) }) .collect(); From 1e47bd3428d0e345cd56c6f64ed6cef7b79c9493 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:55:35 +0100 Subject: [PATCH 141/147] fix(ui): compare WalletSeedHash in shielded ScreenType equality Wildcard matches caused all shielded screens to be considered equal regardless of which wallet they belonged to, breaking multi-wallet navigation. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/mod.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 1165e9e3a..317a200b1 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -429,13 +429,13 @@ impl PartialEq for ScreenType { (ScreenType::DashPayQRGenerator, ScreenType::DashPayQRGenerator) => true, (ScreenType::DashPayProfileSearch, ScreenType::DashPayProfileSearch) => true, // Shielded screens - (ScreenType::ShieldCreditsScreen(_), ScreenType::ShieldCreditsScreen(_)) => true, + (ScreenType::ShieldCreditsScreen(a), ScreenType::ShieldCreditsScreen(b)) => a == b, ( - ScreenType::ShieldFromAssetLockScreen(_), - ScreenType::ShieldFromAssetLockScreen(_), - ) => true, - (ScreenType::ShieldedSendScreen(_), ScreenType::ShieldedSendScreen(_)) => true, - (ScreenType::UnshieldCreditsScreen(_), ScreenType::UnshieldCreditsScreen(_)) => true, + ScreenType::ShieldFromAssetLockScreen(a), + ScreenType::ShieldFromAssetLockScreen(b), + ) => a == b, + (ScreenType::ShieldedSendScreen(a), ScreenType::ShieldedSendScreen(b)) => a == b, + (ScreenType::UnshieldCreditsScreen(a), ScreenType::UnshieldCreditsScreen(b)) => a == b, _ => false, } } From 1b276dd3daef2c94c74e8d6a597661ae4c1e6a75 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:55:57 +0100 Subject: [PATCH 142/147] fix(ui): reset all transient state in ShieldedTabView::update_seed_hash Switching wallets left stale initializing/syncing flags, pending tasks, and address count from the previous wallet, causing UI glitches. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/shielded_tab.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/ui/wallets/shielded_tab.rs b/src/ui/wallets/shielded_tab.rs index db916a140..ee29a20d1 100644 --- a/src/ui/wallets/shielded_tab.rs +++ b/src/ui/wallets/shielded_tab.rs @@ -59,6 +59,10 @@ impl ShieldedTabView { self.shielded_balance = 0; self.error_message = None; self.success_message = None; + self.initializing = false; + self.syncing = false; + self.pending_task = None; + self.address_count = 1; } } From b2076bbc948c7b5d9e2ecd094704520785984877 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:57:03 +0100 Subject: [PATCH 143/147] fix(error): replace string-based SPV payment errors with typed TaskError variants Add WalletInfoUnavailable, MissingBip44Account, and ChangeAddressDerivation variants to TaskError, replacing ad-hoc WalletPaymentFailed { detail: String } usage for these three recurring error patterns in SPV payment code. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend_task/core/mod.rs | 52 ++++++++++++++---------------------- src/backend_task/error.rs | 15 +++++++++++ 2 files changed, 35 insertions(+), 32 deletions(-) diff --git a/src/backend_task/core/mod.rs b/src/backend_task/core/mod.rs index 2136b401b..1e647ba12 100644 --- a/src/backend_task/core/mod.rs +++ b/src/backend_task/core/mod.rs @@ -697,17 +697,15 @@ impl AppContext { // Get UTXOs and change address from the wallet account let (utxos, change_index) = { - let managed_info = - wm.get_wallet_info(wallet_id) - .ok_or_else(|| TaskError::WalletPaymentFailed { - detail: "Wallet info unavailable".to_string(), - })?; + let managed_info = wm + .get_wallet_info(wallet_id) + .ok_or(TaskError::WalletInfoUnavailable)?; let account = managed_info .accounts() .standard_bip44_accounts .get(&DEFAULT_BIP44_ACCOUNT_INDEX) - .ok_or_else(|| TaskError::WalletPaymentFailed { - detail: "BIP44 account missing".to_string(), + .ok_or(TaskError::MissingBip44Account { + index: DEFAULT_BIP44_ACCOUNT_INDEX, })?; let utxos: Vec<_> = account.utxos.values().cloned().collect(); @@ -717,21 +715,17 @@ impl AppContext { let wallet = wm .get_wallet(wallet_id) - .ok_or_else(|| TaskError::WalletPaymentFailed { - detail: "Wallet object not found".to_string(), - })?; + .ok_or(TaskError::WalletInfoUnavailable)?; let wallet_account = wallet .accounts .standard_bip44_accounts .get(&DEFAULT_BIP44_ACCOUNT_INDEX) - .ok_or_else(|| TaskError::WalletPaymentFailed { - detail: "BIP44 wallet account missing".to_string(), + .ok_or(TaskError::MissingBip44Account { + index: DEFAULT_BIP44_ACCOUNT_INDEX, })?; let change_addr = wallet_account .derive_change_address(change_index) - .map_err(|e| TaskError::WalletPaymentFailed { - detail: format!("Failed to derive change address: {e}"), - })?; + .map_err(|e| TaskError::ChangeAddressDerivation { source: e })?; loop { let scaled_recipients: Vec<(Address, u64)> = recipients @@ -801,17 +795,15 @@ impl AppContext { account_index: u32, current_height: u32, ) -> Result { - let managed_info = - wm.get_wallet_info(wallet_id) - .ok_or_else(|| TaskError::WalletPaymentFailed { - detail: "Wallet info unavailable".to_string(), - })?; + let managed_info = wm + .get_wallet_info(wallet_id) + .ok_or(TaskError::WalletInfoUnavailable)?; let collection = managed_info.accounts(); let account = collection .standard_bip44_accounts .get(&account_index) - .ok_or_else(|| TaskError::WalletPaymentFailed { - detail: "BIP44 account missing".to_string(), + .ok_or(TaskError::MissingBip44Account { + index: account_index, })?; let mut spendable_total = 0u64; @@ -896,20 +888,16 @@ impl AppContext { ) -> Result { let wallet = wm .get_wallet(wallet_id) - .ok_or_else(|| TaskError::WalletPaymentFailed { - detail: "Wallet object not found".to_string(), - })?; - let managed_info = - wm.get_wallet_info(wallet_id) - .ok_or_else(|| TaskError::WalletPaymentFailed { - detail: "Wallet info unavailable".to_string(), - })?; + .ok_or(TaskError::WalletInfoUnavailable)?; + let managed_info = wm + .get_wallet_info(wallet_id) + .ok_or(TaskError::WalletInfoUnavailable)?; let accounts = managed_info.accounts(); let account = accounts .standard_bip44_accounts .get(&DEFAULT_BIP44_ACCOUNT_INDEX) - .ok_or_else(|| TaskError::WalletPaymentFailed { - detail: "BIP44 account missing".to_string(), + .ok_or(TaskError::MissingBip44Account { + index: DEFAULT_BIP44_ACCOUNT_INDEX, })?; let secp = Secp256k1::new(); diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index 8e22bed9b..1a3a011ac 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -676,6 +676,21 @@ pub enum TaskError { #[error("Could not complete the payment. Please check your wallet balance and retry.")] WalletPaymentFailed { detail: String }, + /// Could not access wallet information from the SPV manager. + #[error("Could not access wallet information. Please try again.")] + WalletInfoUnavailable, + + /// Expected BIP44 account not found at the given index. + #[error("No BIP44 account found at the expected index. Please refresh your wallet.")] + MissingBip44Account { index: u32 }, + + /// Could not derive a change address from the wallet account. + #[error("Could not derive a change address. Please try again.")] + ChangeAddressDerivation { + #[source] + source: dash_sdk::dpp::key_wallet::Error, + }, + // ────────────────────────────────────────────────────────────────────────── // Token query errors (identity / recipient validation) // ────────────────────────────────────────────────────────────────────────── From 716139810953098cecbb61ea6546b0bbe87598a0 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:57:23 +0100 Subject: [PATCH 144/147] fix(perf): skip Halo2 proving key warmup in test builds The ~30s background thread for proving key generation is unnecessary during tests and slows down the test suite. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app.rs b/src/app.rs index c9aff302a..4cfb6c058 100644 --- a/src/app.rs +++ b/src/app.rs @@ -820,6 +820,7 @@ impl AppState { // Warm up the Halo 2 ProvingKey in a background thread (~30s build). // This ensures the key is ready for the user's first shielded operation. + #[cfg(not(feature = "testing"))] std::thread::spawn(|| { let _ = crate::context::shielded::get_proving_key(); tracing::info!("Halo 2 ProvingKey built and cached"); From 52b55727ce1d88d07d03670c65d45170d3ca88cc Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:58:05 +0100 Subject: [PATCH 145/147] fix(ui): delegate is_platform_address and rewrite hints as full sentences is_platform_address now delegates to is_platform_address_string for consistent case-insensitive HRP matching. Address hint constants are rewritten as complete, i18n-extractable sentences. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/helpers.rs | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/ui/helpers.rs b/src/ui/helpers.rs index a522eb721..24768f296 100644 --- a/src/ui/helpers.rs +++ b/src/ui/helpers.rs @@ -1115,18 +1115,16 @@ pub fn show_group_token_success_screen_with_fee( /// Check if a string looks like a Platform Bech32m address. /// -/// Supports both the current prefix (`dash1`/`tdash1`) and the legacy -/// prefix (`evo1`/`tevo1`) so that old addresses stored in the DB or -/// copied from older tools continue to work. +/// Delegates to [`is_platform_address_string`] which uses the canonical +/// HRP constants and case-insensitive comparison. pub fn is_platform_address(s: &str) -> bool { - s.starts_with("dash1") - || s.starts_with("tdash1") - || s.starts_with("evo1") - || s.starts_with("tevo1") + is_platform_address_string(s) } /// Human-readable hint for Platform address input fields. -pub const PLATFORM_ADDRESS_HINT: &str = "dash1... or tdash1..."; +pub const PLATFORM_ADDRESS_HINT: &str = + "Enter a Platform address starting with \"dash1\" or \"tdash1\"."; /// Example Platform address prefixes for error messages. -pub const PLATFORM_ADDRESS_EXAMPLES: &str = "dash1.../tdash1..."; +pub const PLATFORM_ADDRESS_EXAMPLES: &str = + "Valid prefixes are \"dash1\" for mainnet and \"tdash1\" for testnet."; From e057e522c75c87c50961bb6a514ab7e97fb76fea Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:58:44 +0100 Subject: [PATCH 146/147] fix(ui): sanitize raw RPC error strings in connection status display Strip OS-level error details like "(os error 111)" and transport error wrappers before storing in ConnectionStatus, so users see clean messages instead of raw system error text. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend_task/core/mod.rs | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/backend_task/core/mod.rs b/src/backend_task/core/mod.rs index 1e647ba12..6282ec748 100644 --- a/src/backend_task/core/mod.rs +++ b/src/backend_task/core/mod.rs @@ -197,10 +197,10 @@ impl AppContext { if let Some(task_err) = Self::chain_lock_rpc_error(active_config, e) { return Err(task_err); } - // Non-auth, non-connection error — show the actual error + // Non-auth, non-connection error — show a sanitized message // in the Networks page status display for debugging. tracing::warn!(network = ?self.network, error = %e, "Chain lock query failed on active network"); - Some(format!("RPC error: {e}")) + Some(sanitize_rpc_error(&e.to_string())) } else { // Successful chain lock fetch — clear any lingering RPC error // so the connection status recovers after a transient outage. @@ -987,3 +987,29 @@ impl AppContext { size } } + +/// Sanitize raw RPC error strings for display in connection status. +/// +/// Strips OS-level error details like "(os error 111)" and the "RPC error:" +/// prefix noise, keeping only the meaningful description. +fn sanitize_rpc_error(raw: &str) -> String { + let mut s = raw.to_string(); + + // Strip trailing OS error codes: "Connection refused (os error 111)" -> "Connection refused" + if let Some(pos) = s.find("(os error") { + s = s[..pos].trim_end().to_string(); + } + + // Strip nested "transport error:" or "JSON-RPC error:" wrappers + for prefix in &["transport error:", "JSON-RPC error:"] { + if let Some(pos) = s.find(prefix) { + s = s[pos + prefix.len()..].trim_start().to_string(); + } + } + + if s.is_empty() { + "Could not reach the node.".to_string() + } else { + format!("RPC: {s}") + } +} From 3c8466afd9d2d073ddc5935e2f3fcbbc60a4d759 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:00:49 +0100 Subject: [PATCH 147/147] fix(error): rewrite SPV error messages for everyday users - WalletInfoUnavailable: "wallet is still loading" instead of technical jargon - MissingBip44Account: "needs to be refreshed" instead of "BIP44 account" - ChangeAddressDerivation: "could not prepare transaction" instead of "derive change address" - sanitize_rpc_error: fix doc/impl mismatch, strip "RPC error:" prefix, remove redundant "RPC: " output prefix Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend_task/core/mod.rs | 10 +++++----- src/backend_task/error.rs | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/backend_task/core/mod.rs b/src/backend_task/core/mod.rs index 6282ec748..11e460815 100644 --- a/src/backend_task/core/mod.rs +++ b/src/backend_task/core/mod.rs @@ -990,8 +990,8 @@ impl AppContext { /// Sanitize raw RPC error strings for display in connection status. /// -/// Strips OS-level error details like "(os error 111)" and the "RPC error:" -/// prefix noise, keeping only the meaningful description. +/// Strips OS-level error details and transport/RPC wrappers, keeping only +/// the meaningful description for the Networks page status display. fn sanitize_rpc_error(raw: &str) -> String { let mut s = raw.to_string(); @@ -1000,8 +1000,8 @@ fn sanitize_rpc_error(raw: &str) -> String { s = s[..pos].trim_end().to_string(); } - // Strip nested "transport error:" or "JSON-RPC error:" wrappers - for prefix in &["transport error:", "JSON-RPC error:"] { + // Strip nested wrapper prefixes to get the actual error message + for prefix in &["RPC error:", "transport error:", "JSON-RPC error:"] { if let Some(pos) = s.find(prefix) { s = s[pos + prefix.len()..].trim_start().to_string(); } @@ -1010,6 +1010,6 @@ fn sanitize_rpc_error(raw: &str) -> String { if s.is_empty() { "Could not reach the node.".to_string() } else { - format!("RPC: {s}") + s } } diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index 1a3a011ac..d7b3ab8dd 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -677,15 +677,15 @@ pub enum TaskError { WalletPaymentFailed { detail: String }, /// Could not access wallet information from the SPV manager. - #[error("Could not access wallet information. Please try again.")] + #[error("Your wallet is still loading. Please wait a moment and try again.")] WalletInfoUnavailable, /// Expected BIP44 account not found at the given index. - #[error("No BIP44 account found at the expected index. Please refresh your wallet.")] + #[error("Your wallet needs to be refreshed before sending. Please refresh and try again.")] MissingBip44Account { index: u32 }, /// Could not derive a change address from the wallet account. - #[error("Could not derive a change address. Please try again.")] + #[error("Could not prepare the transaction. Please refresh your wallet and try again.")] ChangeAddressDerivation { #[source] source: dash_sdk::dpp::key_wallet::Error,