diff --git a/Cargo.lock b/Cargo.lock index f3c36a9cc..a579a7748 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -730,9 +730,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.39.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" +checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" dependencies = [ "cc", "cmake", @@ -1030,16 +1030,16 @@ dependencies = [ [[package]] name = "blake3" -version = "1.8.3" +version = "1.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" +checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e" dependencies = [ "arrayref", "arrayvec", "cc", "cfg-if", "constant_time_eq", - "cpufeatures 0.2.17", + "cpufeatures 0.3.0", ] [[package]] @@ -1281,9 +1281,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.57" +version = "1.2.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" dependencies = [ "find-msvc-tools", "jobserver", @@ -1526,9 +1526,9 @@ dependencies = [ [[package]] name = "cmake" -version = "0.1.57" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" dependencies = [ "cc", ] @@ -1848,12 +1848,11 @@ dependencies = [ [[package]] name = "dapi-grpc" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" dependencies = [ "dash-platform-macros", "futures-core", "getrandom 0.2.17", - "platform-version", + "platform-version 3.1.0-dev.1", "prost", "serde", "serde_bytes", @@ -1950,15 +1949,24 @@ dependencies = [ [[package]] name = "dash-context-provider" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" dependencies = [ - "dpp", - "drive", + "dpp 3.1.0-dev.1", + "drive 3.1.0-dev.1", "hex", "serde_json", "thiserror 1.0.69", ] +[[package]] +name = "dash-context-provider" +version = "3.1.0-dev.1" +source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" +dependencies = [ + "dpp 3.1.0-dev.1 (git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829)", + "drive 3.1.0-dev.1 (git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829)", + "thiserror 1.0.69", +] + [[package]] name = "dash-evo-tool" version = "1.0.0-dev" @@ -2000,6 +2008,7 @@ dependencies = [ "native-dialog", "nix", "objc2 0.6.4", + "platform-wallet", "qrcode", "rand 0.9.2", "raw-cpuid", @@ -2039,7 +2048,6 @@ dependencies = [ [[package]] name = "dash-platform-macros" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" dependencies = [ "heck", "quote", @@ -2049,7 +2057,6 @@ dependencies = [ [[package]] name = "dash-sdk" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" dependencies = [ "arc-swap", "async-trait", @@ -2057,12 +2064,12 @@ dependencies = [ "chrono", "ciborium", "dapi-grpc", - "dash-context-provider", + "dash-context-provider 3.1.0-dev.1", "dash-platform-macros", "derive_more 1.0.0", "dotenvy", - "dpp", - "drive", + "dpp 3.1.0-dev.1", + "drive 3.1.0-dev.1", "drive-proof-verifier", "envy", "futures", @@ -2085,7 +2092,6 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" dependencies = [ "anyhow", "async-trait", @@ -2118,7 +2124,6 @@ dependencies = [ [[package]] name = "dashcore" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" dependencies = [ "anyhow", "base64-compat", @@ -2143,12 +2148,10 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" [[package]] name = "dashcore-rpc" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" dependencies = [ "dashcore-rpc-json", "hex", @@ -2161,7 +2164,6 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" dependencies = [ "bincode 2.0.1", "dashcore", @@ -2176,7 +2178,6 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" dependencies = [ "bincode 2.0.1", "dashcore-private", @@ -2201,10 +2202,9 @@ dependencies = [ [[package]] name = "dashpay-contract" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" dependencies = [ - "platform-value", - "platform-version", + "platform-value 3.1.0-dev.1", + "platform-version 3.1.0-dev.1", "serde_json", "thiserror 2.0.18", ] @@ -2212,20 +2212,32 @@ dependencies = [ [[package]] name = "data-contracts" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" dependencies = [ "dashpay-contract", "dpns-contract", "feature-flags-contract", "keyword-search-contract", "masternode-reward-shares-contract", - "platform-value", - "platform-version", + "platform-value 3.1.0-dev.1", + "platform-version 3.1.0-dev.1", "serde_json", "thiserror 2.0.18", - "token-history-contract", + "token-history-contract 3.1.0-dev.1", "wallet-utils-contract", - "withdrawals-contract", + "withdrawals-contract 3.1.0-dev.1", +] + +[[package]] +name = "data-contracts" +version = "3.1.0-dev.1" +source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" +dependencies = [ + "platform-value 3.1.0-dev.1 (git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829)", + "platform-version 3.1.0-dev.1 (git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829)", + "serde_json", + "thiserror 2.0.18", + "token-history-contract 3.1.0-dev.1 (git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829)", + "withdrawals-contract 3.1.0-dev.1 (git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829)", ] [[package]] @@ -2358,9 +2370,9 @@ dependencies = [ [[package]] name = "dircpy" -version = "0.3.19" +version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88521b0517f5f9d51d11925d8ab4523497dcf947073fa3231a311b63941131c" +checksum = "ebcbec2b9a580ddee352ac38523d2ecd4dcaad53532957034394556909e27f4b" dependencies = [ "jwalk", "log", @@ -2465,10 +2477,9 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "dpns-contract" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" dependencies = [ - "platform-value", - "platform-version", + "platform-value 3.1.0-dev.1", + "platform-version 3.1.0-dev.1", "serde_json", "thiserror 2.0.18", ] @@ -2476,7 +2487,6 @@ dependencies = [ [[package]] name = "dpp" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" dependencies = [ "anyhow", "async-trait", @@ -2491,9 +2501,9 @@ dependencies = [ "dash-spv", "dashcore", "dashcore-rpc", - "data-contracts", + "data-contracts 3.1.0-dev.1", "derive_more 1.0.0", - "dpp-json-convertible-derive", + "dpp-json-convertible-derive 3.1.0-dev.1", "env_logger", "getrandom 0.2.17", "grovedb-commitment-tree", @@ -2507,11 +2517,55 @@ dependencies = [ "nohash-hasher", "num_enum 0.7.6", "once_cell", - "platform-serialization", - "platform-serialization-derive", - "platform-value", - "platform-version", - "platform-versioning", + "platform-serialization 3.1.0-dev.1", + "platform-serialization-derive 3.1.0-dev.1", + "platform-value 3.1.0-dev.1", + "platform-version 3.1.0-dev.1", + "platform-versioning 3.1.0-dev.1", + "rand 0.8.5", + "regex", + "serde", + "serde_json", + "serde_repr", + "sha2", + "strum", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "dpp" +version = "3.1.0-dev.1" +source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.22.1", + "bech32 0.11.1", + "bincode 2.0.1", + "bs58", + "byteorder", + "chrono", + "dashcore", + "dashcore-rpc", + "data-contracts 3.1.0-dev.1 (git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829)", + "derive_more 1.0.0", + "dpp-json-convertible-derive 3.1.0-dev.1 (git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829)", + "env_logger", + "getrandom 0.2.17", + "hex", + "indexmap 2.13.0", + "integer-encoding", + "itertools 0.13.0", + "lazy_static", + "nohash-hasher", + "num_enum 0.7.6", + "once_cell", + "platform-serialization 3.1.0-dev.1 (git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829)", + "platform-serialization-derive 3.1.0-dev.1 (git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829)", + "platform-value 3.1.0-dev.1 (git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829)", + "platform-version 3.1.0-dev.1 (git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829)", + "platform-versioning 3.1.0-dev.1 (git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829)", "rand 0.8.5", "regex", "serde", @@ -2523,6 +2577,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "dpp-json-convertible-derive" +version = "3.1.0-dev.1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "dpp-json-convertible-derive" version = "3.1.0-dev.1" @@ -2536,12 +2599,11 @@ dependencies = [ [[package]] name = "drive" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" dependencies = [ "bincode 2.0.1", "byteorder", "derive_more 1.0.0", - "dpp", + "dpp 3.1.0-dev.1", "grovedb 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", "grovedb-epoch-based-storage-flags", @@ -2551,7 +2613,7 @@ dependencies = [ "indexmap 2.13.0", "integer-encoding", "nohash-hasher", - "platform-version", + "platform-version 3.1.0-dev.1", "serde", "sqlparser", "thiserror 2.0.18", @@ -2559,20 +2621,43 @@ dependencies = [ ] [[package]] -name = "drive-proof-verifier" +name = "drive" version = "3.1.0-dev.1" source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" +dependencies = [ + "bincode 2.0.1", + "byteorder", + "derive_more 1.0.0", + "dpp 3.1.0-dev.1 (git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829)", + "grovedb 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", + "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", + "grovedb-epoch-based-storage-flags", + "grovedb-path 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", + "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", + "hex", + "indexmap 2.13.0", + "integer-encoding", + "nohash-hasher", + "platform-version 3.1.0-dev.1 (git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829)", + "sqlparser", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "drive-proof-verifier" +version = "3.1.0-dev.1" dependencies = [ "bincode 2.0.1", "dapi-grpc", - "dash-context-provider", + "dash-context-provider 3.1.0-dev.1", "derive_more 1.0.0", - "dpp", - "drive", + "dpp 3.1.0-dev.1", + "drive 3.1.0-dev.1", "hex", "indexmap 2.13.0", - "platform-serialization", - "platform-serialization-derive", + "platform-serialization 3.1.0-dev.1", + "platform-serialization-derive 3.1.0-dev.1", "serde", "tenderdash-abci", "thiserror 2.0.18", @@ -3151,10 +3236,9 @@ dependencies = [ [[package]] name = "feature-flags-contract" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" dependencies = [ - "platform-value", - "platform-version", + "platform-value 3.1.0-dev.1", + "platform-version 3.1.0-dev.1", "serde_json", "thiserror 2.0.18", ] @@ -4343,9 +4427,9 @@ checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" [[package]] name = "hyper" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -4358,7 +4442,6 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -4447,7 +4530,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.62.2", ] [[package]] @@ -4461,12 +4544,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -4474,9 +4558,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -4487,9 +4571,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -4501,15 +4585,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -4521,15 +4605,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -4683,9 +4767,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iri-string" -version = "0.7.11" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" dependencies = [ "memchr", "serde", @@ -4840,10 +4924,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -4895,7 +4981,6 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" dependencies = [ "async-trait", "base58ck", @@ -4917,23 +5002,22 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" dependencies = [ "async-trait", "dashcore", "key-wallet", "rayon", "tokio", + "tracing", "zeroize", ] [[package]] name = "keyword-search-contract" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" dependencies = [ - "platform-value", - "platform-version", + "platform-value 3.1.0-dev.1", + "platform-version 3.1.0-dev.1", "serde_json", "thiserror 2.0.18", ] @@ -5015,9 +5099,9 @@ checksum = "744a4c881f502e98c2241d2e5f50040ac73b30194d64452bb6260393b53f0dc9" [[package]] name = "libc" -version = "0.2.183" +version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" [[package]] name = "libloading" @@ -5072,9 +5156,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "litrs" @@ -5136,10 +5220,9 @@ dependencies = [ [[package]] name = "masternode-reward-shares-contract" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" dependencies = [ - "platform-value", - "platform-version", + "platform-value 3.1.0-dev.1", + "platform-version 3.1.0-dev.1", "serde_json", "thiserror 2.0.18", ] @@ -5262,9 +5345,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", @@ -6022,9 +6105,9 @@ dependencies = [ [[package]] name = "ordered-float" -version = "5.1.0" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4779c6901a562440c3786d08192c6fbda7c1c2060edd10006b05ee35d10f2d" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" dependencies = [ "num-traits", ] @@ -6284,7 +6367,6 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "platform-encryption" version = "2.1.1" -source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" dependencies = [ "aes", "cbc", @@ -6292,13 +6374,31 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "platform-serialization" +version = "3.1.0-dev.1" +dependencies = [ + "bincode 2.0.1", + "platform-version 3.1.0-dev.1", +] + [[package]] name = "platform-serialization" version = "3.1.0-dev.1" source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" dependencies = [ "bincode 2.0.1", - "platform-version", + "platform-version 3.1.0-dev.1 (git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829)", +] + +[[package]] +name = "platform-serialization-derive" +version = "3.1.0-dev.1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "virtue 0.0.17", ] [[package]] @@ -6312,6 +6412,25 @@ dependencies = [ "virtue 0.0.17", ] +[[package]] +name = "platform-value" +version = "3.1.0-dev.1" +dependencies = [ + "base64 0.22.1", + "bincode 2.0.1", + "bs58", + "ciborium", + "hex", + "indexmap 2.13.0", + "platform-serialization 3.1.0-dev.1", + "platform-version 3.1.0-dev.1", + "rand 0.8.5", + "serde", + "serde_json", + "thiserror 2.0.18", + "treediff", +] + [[package]] name = "platform-value" version = "3.1.0-dev.1" @@ -6323,8 +6442,8 @@ dependencies = [ "ciborium", "hex", "indexmap 2.13.0", - "platform-serialization", - "platform-version", + "platform-serialization 3.1.0-dev.1 (git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829)", + "platform-version 3.1.0-dev.1 (git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829)", "rand 0.8.5", "serde", "serde_json", @@ -6332,6 +6451,16 @@ dependencies = [ "treediff", ] +[[package]] +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=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", + "thiserror 2.0.18", + "versioned-feature-core 1.0.0 (git+https://github.com/dashpay/versioned-feature-core)", +] + [[package]] name = "platform-version" version = "3.1.0-dev.1" @@ -6343,6 +6472,15 @@ dependencies = [ "versioned-feature-core 1.0.0 (git+https://github.com/dashpay/versioned-feature-core)", ] +[[package]] +name = "platform-versioning" +version = "3.1.0-dev.1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "platform-versioning" version = "3.1.0-dev.1" @@ -6353,6 +6491,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "platform-wallet" +version = "3.1.0-dev.1" +dependencies = [ + "async-trait", + "dash-sdk", + "dash-spv", + "dashcore", + "dpp 3.1.0-dev.1", + "hex", + "indexmap 2.13.0", + "key-wallet", + "key-wallet-manager", + "platform-encryption", + "thiserror 1.0.69", + "tokio", + "tokio-util", + "tracing", + "zeroize", +] + [[package]] name = "png" version = "0.17.16" @@ -6439,9 +6598,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -6493,7 +6652,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.8+spec-1.1.0", + "toml_edit 0.25.10+spec-1.1.0", ] [[package]] @@ -6657,7 +6816,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash 2.1.1", + "rustc-hash 2.1.2", "rustls", "socket2", "thiserror 2.0.18", @@ -6678,7 +6837,7 @@ dependencies = [ "lru-slab", "rand 0.9.2", "ring", - "rustc-hash 2.1.1", + "rustc-hash 2.1.2", "rustls", "rustls-pki-types", "slab", @@ -7143,9 +7302,9 @@ dependencies = [ [[package]] name = "rmcp" -version = "1.2.0" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba6b9d2f0efe2258b23767f1f9e0054cfbcac9c2d6f81a031214143096d7864f" +checksum = "2231b2c085b371c01bc90c0e6c1cab8834711b6394533375bdbf870b0166d419" dependencies = [ "async-trait", "bytes", @@ -7174,9 +7333,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "1.2.0" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab9d95d7ed26ad8306352b0d5f05b593222b272790564589790d210aa15caa9e" +checksum = "36ea0e100fadf81be85d7ff70f86cd805c7572601d4ab2946207f36540854b43" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -7216,7 +7375,6 @@ dependencies = [ [[package]] name = "rs-dapi-client" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" dependencies = [ "backon", "chrono", @@ -7245,8 +7403,8 @@ version = "3.1.0-dev.1" source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" dependencies = [ "arc-swap", - "dash-context-provider", - "dpp", + "dash-context-provider 3.1.0-dev.1 (git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829)", + "dpp 3.1.0-dev.1 (git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829)", "futures", "hex", "lru", @@ -7336,9 +7494,9 @@ checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustc_version" @@ -7852,9 +8010,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "simd_cesu8" @@ -8445,9 +8603,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -8468,13 +8626,23 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "token-history-contract" +version = "3.1.0-dev.1" +dependencies = [ + "platform-value 3.1.0-dev.1", + "platform-version 3.1.0-dev.1", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "token-history-contract" version = "3.1.0-dev.1" source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" dependencies = [ - "platform-value", - "platform-version", + "platform-value 3.1.0-dev.1 (git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829)", + "platform-version 3.1.0-dev.1 (git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829)", "serde_json", "thiserror 2.0.18", ] @@ -8607,9 +8775,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.0+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] @@ -8640,23 +8808,23 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.8+spec-1.1.0" +version = "0.25.10+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" +checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b" dependencies = [ "indexmap 2.13.0", - "toml_datetime 1.1.0+spec-1.1.0", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.0", + "winnow 1.0.1", ] [[package]] name = "toml_parser" -version = "1.1.0+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.0", + "winnow 1.0.1", ] [[package]] @@ -8904,7 +9072,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" dependencies = [ - "rustc-hash 2.1.1", + "rustc-hash 2.1.2", ] [[package]] @@ -9010,9 +9178,9 @@ checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" [[package]] name = "unicode-segmentation" -version = "1.13.1" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da36089a805484bcccfffe0739803392c8298778a2d2f09febf76fac5ad9025b" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-vo" @@ -9155,9 +9323,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.22.0" +version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -9270,10 +9438,9 @@ dependencies = [ [[package]] name = "wallet-utils-contract" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" dependencies = [ - "platform-value", - "platform-version", + "platform-value 3.1.0-dev.1", + "platform-version 3.1.0-dev.1", "serde_json", "thiserror 2.0.18", ] @@ -9313,9 +9480,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" dependencies = [ "cfg-if", "once_cell", @@ -9326,23 +9493,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.64" +version = "0.4.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" dependencies = [ - "cfg-if", - "futures-util", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -9350,9 +9513,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" dependencies = [ "bumpalo", "proc-macro2", @@ -9363,9 +9526,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" dependencies = [ "unicode-ident", ] @@ -9432,9 +9595,9 @@ dependencies = [ [[package]] name = "wayland-backend" -version = "0.3.14" +version = "0.3.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa75f400b7f719bcd68b3f47cd939ba654cedeef690f486db71331eec4c6a406" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" dependencies = [ "cc", "downcast-rs", @@ -9446,9 +9609,9 @@ dependencies = [ [[package]] name = "wayland-client" -version = "0.31.13" +version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab51d9f7c071abeee76007e2b742499e535148035bb835f97aaed1338cf516c3" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" dependencies = [ "bitflags 2.11.0", "rustix 1.1.4", @@ -9469,9 +9632,9 @@ dependencies = [ [[package]] name = "wayland-cursor" -version = "0.31.13" +version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b3298683470fbdc6ca40151dfc48c8f2fd4c41a26e13042f801f85002384091" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" dependencies = [ "rustix 1.1.4", "wayland-client", @@ -9480,9 +9643,9 @@ dependencies = [ [[package]] name = "wayland-protocols" -version = "0.32.11" +version = "0.32.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b23b5df31ceff1328f06ac607591d5ba360cf58f90c8fad4ac8d3a55a3c4aec7" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" dependencies = [ "bitflags 2.11.0", "wayland-backend", @@ -9505,9 +9668,9 @@ dependencies = [ [[package]] name = "wayland-protocols-misc" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "429b99200febaf95d4f4e46deff6fe4382bcff3280ee16a41cf887b3c3364984" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" dependencies = [ "bitflags 2.11.0", "wayland-backend", @@ -9518,9 +9681,9 @@ dependencies = [ [[package]] name = "wayland-protocols-plasma" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d392fc283a87774afc9beefcd6f931582bb97fe0e6ced0b306a62cb1d026527c" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" dependencies = [ "bitflags 2.11.0", "wayland-backend", @@ -9531,9 +9694,9 @@ dependencies = [ [[package]] name = "wayland-protocols-wlr" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78248e4cc0eff8163370ba5c158630dcae1f3497a586b826eca2ef5f348d6235" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ "bitflags 2.11.0", "wayland-backend", @@ -9544,9 +9707,9 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.9" +version = "0.31.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c86287151a309799b821ca709b7345a048a2956af05957c89cb824ab919fa4e3" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" dependencies = [ "proc-macro2", "quick-xml 0.39.2", @@ -9555,9 +9718,9 @@ dependencies = [ [[package]] name = "wayland-sys" -version = "0.31.10" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "374f6b70e8e0d6bf9461a32988fd553b59ff630964924dad6e4a4eb6bd538d17" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" dependencies = [ "dlib", "log", @@ -9567,9 +9730,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.91" +version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" dependencies = [ "js-sys", "wasm-bindgen", @@ -9914,6 +10077,19 @@ 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" @@ -10435,9 +10611,9 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" dependencies = [ "memchr", ] @@ -10658,14 +10834,27 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "withdrawals-contract" +version = "3.1.0-dev.1" +dependencies = [ + "num_enum 0.5.11", + "platform-value 3.1.0-dev.1", + "platform-version 3.1.0-dev.1", + "serde", + "serde_json", + "serde_repr", + "thiserror 2.0.18", +] + [[package]] name = "withdrawals-contract" version = "3.1.0-dev.1" source = "git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829#a10190399d7033e7e56e3f756411e9a5dab87829" dependencies = [ "num_enum 0.5.11", - "platform-value", - "platform-version", + "platform-value 3.1.0-dev.1 (git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829)", + "platform-version 3.1.0-dev.1 (git+https://github.com/dashpay/platform?rev=a10190399d7033e7e56e3f756411e9a5dab87829)", "serde", "serde_json", "serde_repr", @@ -10758,9 +10947,9 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -10769,9 +10958,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -10899,18 +11088,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.47" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.47" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", @@ -10919,18 +11108,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -10998,9 +11187,9 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -11009,9 +11198,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -11020,9 +11209,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", @@ -11110,9 +11299,9 @@ checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" [[package]] name = "zune-jpeg" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7a1c0af6e5d8d1363f4994b7a091ccf963d8b694f7da5b0b9cceb82da2c0a6" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] diff --git a/Cargo.toml b/Cargo.toml index f2d0d580a..113e166e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ qrcode = "0.14.1" nix = { version = "0.31.1", features = ["signal"] } eframe = { version = "0.33.3", features = ["persistence", "wgpu"] } base64 = "0.22.1" -dash-sdk = { git = "https://github.com/dashpay/platform", rev = "a10190399d7033e7e56e3f756411e9a5dab87829", features = [ +dash-sdk = { path = "../platform/packages/rs-sdk", features = [ "core_key_wallet", "core_key_wallet_manager", "core_bincode", @@ -28,6 +28,7 @@ dash-sdk = { git = "https://github.com/dashpay/platform", rev = "a10190399d7033e "core_spv", "shielded", ] } +platform-wallet = { path = "../platform/packages/rs-platform-wallet" } rs-sdk-trusted-context-provider = { git = "https://github.com/dashpay/platform", rev = "a10190399d7033e7e56e3f756411e9a5dab87829" } zip32 = "0.2.0" grovestark = { git = "https://www.github.com/dashpay/grovestark", rev = "5b9e289cca54c79b1305d5f4f40bf1148f1eb0e3" } @@ -113,6 +114,16 @@ name = "det-cli" path = "src/bin/det_cli/main.rs" required-features = ["cli"] +[[test]] +name = "e2e" +path = "tests/e2e/main.rs" +required-features = ["testing"] + +[[test]] +name = "backend-e2e" +path = "tests/backend-e2e/main.rs" +required-features = ["testing"] + [build-dependencies] winres = "0.1" @@ -130,3 +141,19 @@ check-cfg = ["cfg(tokio_unstable)", "cfg(feature, values(\"testing\", \"mcp\", \ [lints.clippy] uninlined_format_args = "allow" +# Override the git dashcore crates with local versions (since the platform +# workspace uses `git = "https://github.com/dashpay/rust-dashcore"` for its +# dashcore deps and we have local changes in rust-dashcore). +[patch."https://github.com/dashpay/rust-dashcore"] +dashcore = { path = "../rust-dashcore/dash" } +dash-spv = { path = "../rust-dashcore/dash-spv" } +key-wallet = { path = "../rust-dashcore/key-wallet" } +key-wallet-manager = { path = "../rust-dashcore/key-wallet-manager" } +dashcore-rpc = { path = "../rust-dashcore/rpc-client" } +dashcore-rpc-json = { path = "../rust-dashcore/rpc-json" } + +# dashcore_hashes is published on crates.io but we need the local version +# to match our local dashcore changes. +[patch.crates-io] +dashcore_hashes = { path = "../rust-dashcore/hashes" } + diff --git a/src/app.rs b/src/app.rs index 22e8c4a27..fbb2d8483 100644 --- a/src/app.rs +++ b/src/app.rs @@ -117,6 +117,10 @@ pub struct AppState { /// Shared MCP context -- follows network switches via `ArcSwap`. #[cfg(feature = "mcp")] pub mcp_app_context: Option>>, + /// Keeps the temporary data directory alive for the lifetime of `AppState` + /// when running under the `testing` feature. + #[cfg(feature = "testing")] + _temp_dir: Option, } #[derive(Debug, Clone, PartialEq)] @@ -210,21 +214,31 @@ impl AppState { Self::new_inner(ctx, db, data_dir) } - /// Creates a new `AppState` using an in-memory database for testing. + /// Creates a new `AppState` using an in-memory database and an isolated + /// temporary data directory for testing. /// /// Available only when the `testing` feature is active. This prevents tests - /// from reading or writing the production database. + /// from reading or writing the production database and ensures parallel + /// tests never share the same data directory. #[cfg(feature = "testing")] pub fn new(ctx: egui::Context) -> Result> { - let data_dir = app_user_data_dir_path()?; + // Create an isolated temp directory so parallel tests never collide. + let temp_dir = tempfile::tempdir() + .map_err(|e| format!("Failed to create temp data dir: {}", e))?; + let data_dir = temp_dir.path().to_path_buf(); ensure_data_dir_exists(&data_dir)?; + + // Copy the bundled .env.example so Config::load_from succeeds. ensure_env_file(&data_dir); let db = Arc::new( crate::database::test_helpers::create_test_database() .map_err(|e| format!("Failed to create test database: {}", e))?, ); - Self::new_inner(ctx, db, data_dir) + let mut state = Self::new_inner(ctx, db, data_dir)?; + // Keep the temp dir alive for the lifetime of AppState. + state._temp_dir = Some(temp_dir); + Ok(state) } fn new_inner( @@ -791,6 +805,8 @@ impl AppState { accessibility_retries: 0, #[cfg(feature = "mcp")] mcp_app_context, + #[cfg(feature = "testing")] + _temp_dir: None, }; // Initialize welcome screen if needed (after mainnet_app_context is owned by the struct) @@ -879,6 +895,39 @@ impl AppState { } } + /// Flush all platform wallet persisters across every network context. + /// + /// Called during shutdown to ensure any staged-but-unflushed changesets + /// (e.g. from `FlushStrategy::Manual`) are written before the process exits. + fn flush_all_wallet_persistence(&self) { + let contexts: Vec<&Arc> = [ + Some(&self.mainnet_app_context), + self.testnet_app_context.as_ref(), + self.devnet_app_context.as_ref(), + self.local_app_context.as_ref(), + ] + .into_iter() + .flatten() + .collect(); + + for ctx in contexts { + if let Ok(wallets) = ctx.wallets.read() { + for wallet_arc in wallets.values() { + if let Ok(wallet) = wallet_arc.read() { + if let Some(pw) = &wallet.platform_wallet { + if let Err(e) = pw.flush_persist() { + tracing::warn!( + error = %e, + "Failed to flush wallet persistence on shutdown" + ); + } + } + } + } + } + } + } + fn context_available_for_network(&self, network: Network) -> bool { match network { Network::Mainnet => true, // Mainnet is always available @@ -1200,6 +1249,7 @@ impl App for AppState { MessageType::Warning, ); tracing::debug!("Close requested, starting async shutdown"); + self.flush_all_wallet_persistence(); self.shutdown_receiver = Some(self.subtasks.shutdown_async()); self.shutdown_started = Some(std::time::Instant::now()); ctx.request_repaint(); @@ -1655,6 +1705,7 @@ impl App for AppState { return; } tracing::debug!("on_exit: fallback blocking shutdown"); + self.flush_all_wallet_persistence(); if let Err(e) = self.subtasks.shutdown() { tracing::error!("Error during task shutdown: {}", e); } diff --git a/src/backend_task/core/create_asset_lock.rs b/src/backend_task/core/create_asset_lock.rs index ff200672e..6846794c5 100644 --- a/src/backend_task/core/create_asset_lock.rs +++ b/src/backend_task/core/create_asset_lock.rs @@ -3,6 +3,7 @@ use crate::backend_task::error::TaskError; use crate::context::AppContext; use crate::model::wallet::Wallet; use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; +use dash_sdk::dpp::dashcore::OutPoint; use dash_sdk::dpp::fee::Credits; use std::sync::{Arc, RwLock}; @@ -11,98 +12,125 @@ impl AppContext { &self, wallet: Arc>, amount: Credits, - allow_take_fee_from_amount: bool, + _allow_take_fee_from_amount: bool, identity_index: u32, ) -> Result { let amount_duffs = amount / CREDITS_PER_DUFF; - let (asset_lock_transaction, _private_key, _change_address, _used_utxos) = { - let mut wallet_guard = wallet.write()?; - - wallet_guard - .registration_asset_lock_transaction( - self, - self.network, - amount_duffs, - allow_take_fee_from_amount, - identity_index, - None, - ) - .map_err(|e| TaskError::AssetLockTransactionBuildFailed { detail: e })? + let (platform_wallet, _seed_hash) = { + let guard = wallet.read()?; + let pw = guard + .platform_wallet + .clone() + .ok_or(TaskError::WalletNotFound)?; + (pw, guard.seed_hash()) }; - let tx_id = asset_lock_transaction.txid(); - - { - let mut proofs = self.transactions_waiting_for_finality.lock()?; - proofs.insert(tx_id, None); - } - - if let Err(e) = self - .broadcast_raw_transaction(&asset_lock_transaction) + let (tx, _private_key) = platform_wallet + .asset_locks() + .build_asset_lock_transaction( + amount_duffs, + 0, + platform_wallet::AssetLockFundingType::IdentityRegistration, + identity_index, + ) .await - { - if let Ok(mut proofs) = self.transactions_waiting_for_finality.lock() { - proofs.remove(&tx_id); - } else { - tracing::warn!( - "Failed to clean up finality tracking for tx {tx_id}: Mutex poisoned" - ); - } - return Err(e); - } + .map_err(|e| TaskError::AssetLockTransactionBuildFailed { + detail: e.to_string(), + })?; - Ok(BackendTaskSuccessResult::Message(format!( - "Asset lock transaction broadcast successfully. TX ID: {}", - tx_id - ))) + let result = self + .broadcast_and_track_asset_lock_via_manager( + tx, + amount_duffs, + platform_wallet::AssetLockFundingType::IdentityRegistration, + identity_index, + &platform_wallet, + ) + .await?; + + Ok(result) } pub async fn create_top_up_asset_lock( &self, wallet: Arc>, amount: Credits, - allow_take_fee_from_amount: bool, + _allow_take_fee_from_amount: bool, identity_index: u32, - top_up_index: u32, + _topup_index: u32, ) -> Result { let amount_duffs = amount / CREDITS_PER_DUFF; - let (asset_lock_transaction, _private_key, _change_address, _used_utxos) = { - let mut wallet_guard = wallet.write()?; - - wallet_guard - .top_up_asset_lock_transaction( - self, - self.network, - amount_duffs, - allow_take_fee_from_amount, - identity_index, - top_up_index, - None, - ) - .map_err(|e| TaskError::AssetLockTransactionBuildFailed { detail: e })? + let (platform_wallet, _seed_hash) = { + let guard = wallet.read()?; + let pw = guard + .platform_wallet + .clone() + .ok_or(TaskError::WalletNotFound)?; + (pw, guard.seed_hash()) }; + let (tx, _private_key) = platform_wallet + .asset_locks() + .build_asset_lock_transaction( + amount_duffs, + 0, + platform_wallet::AssetLockFundingType::IdentityTopUp, + identity_index, + ) + .await + .map_err(|e| TaskError::AssetLockTransactionBuildFailed { + detail: e.to_string(), + })?; + + let result = self + .broadcast_and_track_asset_lock_via_manager( + tx, + amount_duffs, + platform_wallet::AssetLockFundingType::IdentityTopUp, + identity_index, + &platform_wallet, + ) + .await?; + + Ok(result) + } + + /// Build, register, and broadcast an asset lock transaction via + /// AssetLockManager. The TX is tracked internally by the manager; + /// the proof will arrive later via SPV events. + async fn broadcast_and_track_asset_lock_via_manager( + &self, + asset_lock_transaction: dash_sdk::dpp::dashcore::Transaction, + amount_duffs: u64, + funding_type: platform_wallet::AssetLockFundingType, + identity_index: u32, + platform_wallet: &platform_wallet::PlatformWallet, + ) -> Result { let tx_id = asset_lock_transaction.txid(); + let out_point = OutPoint::new(tx_id, 0); - { - let mut proofs = self.transactions_waiting_for_finality.lock()?; - proofs.insert(tx_id, None); - } + // Register with AssetLockManager so SPV events can resolve it. + platform_wallet.asset_locks().recover_asset_lock_blocking( + asset_lock_transaction.clone(), + amount_duffs, + 0, + funding_type, + identity_index, + out_point, + None, + ); + // Broadcast via SPV P2P peers. if let Err(e) = self - .broadcast_raw_transaction(&asset_lock_transaction) + .wallet_manager + .broadcast_transaction(&asset_lock_transaction) .await { - if let Ok(mut proofs) = self.transactions_waiting_for_finality.lock() { - proofs.remove(&tx_id); - } else { - tracing::warn!( - "Failed to clean up finality tracking for tx {tx_id}: Mutex poisoned" - ); - } - return Err(e); + return Err(TaskError::AssetLockTransactionBuildFailed { + detail: e.to_string(), + }); } Ok(BackendTaskSuccessResult::Message(format!( diff --git a/src/backend_task/core/mod.rs b/src/backend_task/core/mod.rs index 28f29edd4..4c7722e66 100644 --- a/src/backend_task/core/mod.rs +++ b/src/backend_task/core/mod.rs @@ -32,8 +32,7 @@ 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::{WalletError, WalletId, WalletManager}; +use dash_sdk::dpp::key_wallet_manager::WalletError; use std::path::PathBuf; use std::str::FromStr; use std::sync::{Arc, RwLock}; @@ -145,11 +144,28 @@ pub enum CoreItem { impl AppContext { /// Extract the seed hash and first known address from an HD wallet. - fn core_wallet_first_address( + /// + /// Reads from PlatformWallet's CoreAddressInfo. Returns `None` for the + /// address when PlatformWallet is not available (locked wallet). + async fn core_wallet_first_address( wallet: &Arc>, ) -> Result<([u8; 32], Option
), TaskError> { - let g = wallet.read()?; - Ok((g.seed_hash(), g.known_addresses.keys().next().cloned())) + let (seed_hash, platform_wallet) = { + let g = wallet.read()?; + (g.seed_hash(), g.platform_wallet.clone()) + }; + + if let Some(pw) = platform_wallet { + let info = pw.state().await; + let first_addr = crate::platform_wallet_bridge::CoreAddressInfo::all_from_wallet_info(info.managed_state.wallet_info()) + .into_iter() + .next() + .map(|a| a.address); + Ok((seed_hash, first_addr)) + } else { + // Locked wallet — no addresses available + Ok((seed_hash, None)) + } } pub async fn run_core_task( @@ -226,7 +242,7 @@ impl AppContext { ))) } CoreTask::RefreshWalletInfo(wallet, sync_platform) => { - let (seed_hash, first_addr) = Self::core_wallet_first_address(&wallet)?; + let (seed_hash, first_addr) = Self::core_wallet_first_address(&wallet).await?; if self.core_backend_mode() == crate::spv::CoreBackendMode::Spv { self.reconcile_spv_wallets().await?; @@ -295,21 +311,21 @@ impl AppContext { .map_err(|e| TaskError::DashCoreStartError { source: e }) .map(|_| BackendTaskSuccessResult::None), CoreTask::CreateRegistrationAssetLock(wallet, amount, identity_index) => { - let (seed_hash, first_addr) = Self::core_wallet_first_address(&wallet)?; + let (seed_hash, first_addr) = Self::core_wallet_first_address(&wallet).await?; let result = self .create_registration_asset_lock(wallet, amount, true, identity_index) .await; self.with_wallet_recovery(&seed_hash, first_addr.as_ref(), false, result) } CoreTask::CreateTopUpAssetLock(wallet, amount, identity_index, top_up_index) => { - let (seed_hash, first_addr) = Self::core_wallet_first_address(&wallet)?; + let (seed_hash, first_addr) = Self::core_wallet_first_address(&wallet).await?; let result = self .create_top_up_asset_lock(wallet, amount, true, identity_index, top_up_index) .await; self.with_wallet_recovery(&seed_hash, first_addr.as_ref(), false, result) } CoreTask::SendWalletPayment { wallet, request } => { - let (seed_hash, first_addr) = Self::core_wallet_first_address(&wallet)?; + let (seed_hash, first_addr) = Self::core_wallet_first_address(&wallet).await?; let result = self.send_wallet_payment(wallet, request).await; self.with_wallet_recovery(&seed_hash, first_addr.as_ref(), false, result) } @@ -322,7 +338,7 @@ impl AppContext { self.with_wallet_recovery(&key_hash, Some(&address), true, result) } CoreTask::RecoverAssetLocks(wallet) => { - let (seed_hash, first_addr) = Self::core_wallet_first_address(&wallet)?; + let (seed_hash, first_addr) = Self::core_wallet_first_address(&wallet).await?; let ctx = self.clone(); let result = tokio::task::spawn_blocking(move || ctx.recover_asset_locks(wallet)).await?; @@ -523,30 +539,36 @@ impl AppContext { ) -> Result { let parsed_recipients = self.parse_recipients(&request)?; - const DEFAULT_TX_FEE: u64 = 1_000; - - let tx = { - let mut wallet_guard = wallet.write()?; - if !wallet_guard.is_open() { + let (platform_wallet, _seed_hash) = { + let guard = wallet.read()?; + if !guard.is_open() { return Err(TaskError::WalletLocked); } - wallet_guard - .build_multi_recipient_payment_transaction( - self, - self.network, - &parsed_recipients, - DEFAULT_TX_FEE, - request.subtract_fee_from_amount, - ) - .map_err(|e| TaskError::WalletPaymentFailed { detail: e })? + let pw = guard + .platform_wallet + .clone() + .ok_or(TaskError::WalletNotFound)?; + (pw, guard.seed_hash()) }; + // Build and sign via PlatformWallet's CoreWallet + let tx = platform_wallet + .core() + .send_transaction(parsed_recipients) + .await + .map_err(|e| TaskError::WalletPaymentFailed { + detail: e.to_string(), + })?; + let txid = self .core_client .read()? .send_raw_transaction(&tx) .map_err(TaskError::from)?; + // Wallet changes (UTXO updates) are auto-flushed via + // FlushStrategy::Immediate when queued by the platform wallet. + let total_amount: u64 = request.recipients.iter().map(|r| r.amount_duffs).sum(); let recipients_result: Vec<(String, u64)> = request .recipients @@ -577,29 +599,30 @@ impl AppContext { guard.seed_hash() }; - let wallet_id = self - .spv_manager - .wallet_id_for_seed(seed_hash) - .ok_or_else(|| TaskError::WalletPaymentFailed { - detail: "Wallet not loaded into SPV".to_string(), - })?; + let pw = self.require_platform_wallet(&seed_hash)?; let tx = { - let wm_arc = self.spv_manager.wallet(); - let mut wm = wm_arc.write().await; - let unsigned = self.build_spv_unsigned_transaction_multi( - &mut wm, - &wallet_id, + let info_guard = + pw + .try_state() + .ok_or_else(|| TaskError::WalletPaymentFailed { + detail: "Wallet info unavailable".to_string(), + })?; + let unsigned = self.build_spv_unsigned_transaction_multi_pw( + info_guard.managed_state.wallet_info(), + info_guard.managed_state.wallet(), &parsed_recipients, &request, )?; - self.sign_spv_transaction(&mut wm, &wallet_id, unsigned)? + self.sign_spv_transaction_pw(info_guard.managed_state.wallet_info(), info_guard.managed_state.wallet(), unsigned)? }; - self.spv_manager + self.wallet_manager .broadcast_transaction(&tx) .await - .map_err(|e| TaskError::SpvBroadcastFailed { detail: e })?; + .map_err(|e| TaskError::SpvBroadcastFailed { + detail: e.to_string(), + })?; self.reconcile_spv_wallets().await?; @@ -669,10 +692,10 @@ impl AppContext { Ok(parsed) } - fn build_spv_unsigned_transaction_multi( + fn build_spv_unsigned_transaction_multi_pw( &self, - wm: &mut WalletManager, - wallet_id: &WalletId, + managed_info: &ManagedWalletInfo, + wallet: &dash_sdk::dpp::key_wallet::wallet::Wallet, recipients: &[(Address, u64)], request: &WalletPaymentRequest, ) -> Result { @@ -680,7 +703,7 @@ impl AppContext { let _network = self.wallet_network_key(); let current_height = self - .spv_manager() + .spv_event_bridge() .status() .sync_progress .and_then(|p| { @@ -701,11 +724,6 @@ 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 account = managed_info .accounts() .standard_bip44_accounts @@ -719,11 +737,6 @@ impl AppContext { (utxos, change_index) }; - let wallet = wm - .get_wallet(wallet_id) - .ok_or_else(|| TaskError::WalletPaymentFailed { - detail: "Wallet object not found".to_string(), - })?; let wallet_account = wallet .accounts .standard_bip44_accounts @@ -767,9 +780,8 @@ impl AppContext { 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, + let fallback_amount = self.estimate_fallback_amount_pw( + managed_info, _network, DEFAULT_BIP44_ACCOUNT_INDEX, current_height, @@ -797,19 +809,13 @@ impl AppContext { } } - fn estimate_fallback_amount( + fn estimate_fallback_amount_pw( &self, - wm: &mut WalletManager, - wallet_id: &WalletId, + managed_info: &ManagedWalletInfo, _network: WalletNetwork, 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 collection = managed_info.accounts(); let account = collection .standard_bip44_accounts @@ -841,17 +847,13 @@ impl AppContext { /// Build an unsigned payment transaction using TransactionBuilder. #[allow(dead_code)] fn build_unsigned_payment_tx( - wm: &mut WalletManager, - wallet_id: &WalletId, + managed_info: &ManagedWalletInfo, 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 @@ -892,22 +894,12 @@ impl AppContext { .map_err(|e: BuilderError| WalletError::TransactionBuild(e.to_string())) } - fn sign_spv_transaction( + fn sign_spv_transaction_pw( &self, - wm: &mut WalletManager, - wallet_id: &WalletId, + managed_info: &ManagedWalletInfo, + wallet: &dash_sdk::dpp::key_wallet::wallet::Wallet, tx: Transaction, ) -> 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(), - })?; let accounts = managed_info.accounts(); let account = accounts .standard_bip44_accounts diff --git a/src/backend_task/core/recover_asset_locks.rs b/src/backend_task/core/recover_asset_locks.rs index 20ff09839..ca6454e5b 100644 --- a/src/backend_task/core/recover_asset_locks.rs +++ b/src/backend_task/core/recover_asset_locks.rs @@ -8,9 +8,34 @@ use dash_sdk::dpp::dashcore::transaction::special_transaction::TransactionPayloa use dash_sdk::dpp::dashcore::{Address, OutPoint}; use dash_sdk::dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; use dash_sdk::dpp::prelude::AssetLockProof; +use crate::platform_wallet_bridge::CoreAddressInfo; use std::collections::HashSet; use std::sync::{Arc, RwLock}; +/// Register a recovered asset lock with the PlatformWallet's AssetLockManager. +/// +/// This keeps the AssetLockManager in sync with evo-tool's own tracking. The +/// `funding_type` is set to `IdentityRegistration` as a default since recovery +/// cannot determine the original funding type. +fn register_with_asset_lock_manager( + wallet: &Wallet, + tx: &dash_sdk::dpp::dashcore::Transaction, + amount: u64, + proof: Option, +) { + if let Some(pw) = wallet.platform_wallet.as_ref() { + pw.asset_locks().recover_asset_lock_blocking( + tx.clone(), + amount, + 0, // account_index unknown for recovered locks, default to 0 + platform_wallet::AssetLockFundingType::IdentityRegistration, + 0, // identity_index unknown for recovered locks + dash_sdk::dpp::dashcore::OutPoint::new(tx.txid(), 0), + proof, + ); + } +} + impl AppContext { /// Search for unused asset locks by scanning the Core wallet for asset lock transactions /// that belong to this wallet but aren't tracked in the database. @@ -18,14 +43,32 @@ impl AppContext { &self, wallet: Arc>, ) -> Result { - let (known_addresses, seed_hash, already_tracked_txids, core_wallet_name) = { + let (wallet_addresses, seed_hash, already_tracked_txids, core_wallet_name) = { let wallet_guard = wallet.read()?; - let addresses: Vec
= wallet_guard.known_addresses.keys().cloned().collect(); + + // Read addresses from PlatformWallet (canonical source). + // Locked wallets (no PlatformWallet) have no addresses — return empty. + let addresses: Vec
= if let Some(pw) = wallet_guard.platform_wallet.as_ref() { + let info = pw.state_blocking(); + CoreAddressInfo::all_from_wallet_info(info.managed_state.wallet_info()) + .into_iter() + .map(|a| a.address) + .collect() + } else { + Vec::new() + }; + let tracked: HashSet<_> = wallet_guard - .unused_asset_locks - .iter() - .map(|(tx, _, _, _, _)| tx.txid()) - .collect(); + .platform_wallet + .as_ref() + .map(|pw| { + pw.asset_locks() + .list_tracked_locks_blocking() + .into_iter() + .map(|lock| lock.out_point.txid) + .collect() + }) + .unwrap_or_default(); ( addresses, wallet_guard.seed_hash(), @@ -35,13 +78,13 @@ impl AppContext { }; tracing::info!( - "Searching for unused asset locks. Known addresses: {}, Already tracked: {}", - known_addresses.len(), + "Searching for unused asset locks. Wallet addresses: {}, Already tracked: {}", + wallet_addresses.len(), already_tracked_txids.len() ); - if known_addresses.is_empty() { - tracing::warn!("No known addresses in wallet - cannot search for asset locks"); + if wallet_addresses.is_empty() { + tracing::warn!("No addresses in wallet - cannot search for asset locks"); return Ok(BackendTaskSuccessResult::RecoveredAssetLocks { recovered_count: 0, total_amount: 0, @@ -53,19 +96,19 @@ impl AppContext { let mut recovered_count = 0; let mut total_amount = 0u64; - // First, import all known addresses to Core to ensure it's watching them - for address in &known_addresses { + // First, import all wallet addresses to Core to ensure it's watching them + for address in &wallet_addresses { if let Err(e) = client.import_address(address, None, Some(false)) { tracing::debug!("import_address for {} returned: {:?}", address, e); } } - // Method 1: Get unspent outputs for all known addresses - let address_refs: Vec<&Address> = known_addresses.iter().collect(); + // Method 1: Get unspent outputs for all wallet addresses + let address_refs: Vec<&Address> = wallet_addresses.iter().collect(); let unspent = client.list_unspent(None, None, Some(&address_refs), Some(true), None)?; tracing::info!( - "Found {} unspent outputs for known addresses", + "Found {} unspent outputs for wallet addresses", unspent.len() ); @@ -110,7 +153,7 @@ impl AppContext { for credit_output in &payload.credit_outputs { if let Ok(addr) = Address::from_script(&credit_output.script_pubkey, self.network) { tracing::debug!("Asset lock credit output address: {}", addr); - if known_addresses.contains(&addr) { + if wallet_addresses.contains(&addr) { credit_address = Some(addr); credit_amount = credit_output.value; break; @@ -119,7 +162,7 @@ impl AppContext { } let Some(addr) = credit_address else { - tracing::debug!("Asset lock {} credit address not in known addresses", txid); + tracing::debug!("Asset lock {} credit address not in wallet addresses", txid); continue; }; @@ -182,33 +225,20 @@ impl AppContext { tracing::warn!("Failed to update chain locked height for {}: {}", txid, e); } - // Add to wallet's in-memory unused_asset_locks + // Register with PlatformWallet's AssetLockManager { - let mut wallet_guard = wallet.write()?; - - let already_exists = wallet_guard - .unused_asset_locks - .iter() - .any(|(tx, _, _, _, _)| tx.txid() == txid); - - if !already_exists { - wallet_guard.unused_asset_locks.push(( - raw_tx.clone(), - addr, - credit_amount, - None, - proof, - )); - recovered_count += 1; - total_amount += credit_amount; - - tracing::info!( - "Found unused asset lock: txid={}, amount={} duffs", - txid, - credit_amount - ); - } + let wallet_guard = wallet.read()?; + register_with_asset_lock_manager(&wallet_guard, &raw_tx, credit_amount, proof); } + + recovered_count += 1; + total_amount += credit_amount; + + tracing::info!( + "Found unused asset lock: txid={}, amount={} duffs", + txid, + credit_amount + ); } // Method 2: Also check Core's wallet for any transactions we might have missed @@ -258,7 +288,7 @@ impl AppContext { }; // Verify the credit address belongs to our wallet - if !known_addresses.contains(&credit_addr) { + if !wallet_addresses.contains(&credit_addr) { tracing::debug!( "Asset lock {} credit address {} not in wallet, skipping", txid, @@ -324,79 +354,23 @@ impl AppContext { tracing::warn!("Failed to update chain locked height for {}: {}", txid, e); } - // Add to wallet + // Register with PlatformWallet's AssetLockManager { - let mut wallet_guard = wallet.write()?; - - let already_exists = wallet_guard - .unused_asset_locks - .iter() - .any(|(tx, _, _, _, _)| tx.txid() == txid); - - if !already_exists { - wallet_guard.unused_asset_locks.push(( - raw_tx.clone(), - credit_addr, - credit_amount, - None, - proof, - )); - recovered_count += 1; - total_amount += credit_amount; - - tracing::info!( - "Found unused asset lock (full scan): txid={}, amount={} duffs", - txid, - credit_amount - ); - } + let wallet_guard = wallet.read()?; + register_with_asset_lock_manager(&wallet_guard, &raw_tx, credit_amount, proof); } - } - } - // Clean up: Remove asset locks from wallet that don't belong to it - // (credit address not in known_addresses) - let mut txids_to_remove = Vec::new(); - let removed_count = { - let mut wallet_guard = wallet.write()?; - let before_count = wallet_guard.unused_asset_locks.len(); - - wallet_guard.unused_asset_locks.retain(|(tx, _, _, _, _)| { - // Get the credit output address from the transaction - if let Some(TransactionPayload::AssetLockPayloadType(payload)) = - &tx.special_transaction_payload - && let Some(credit_output) = payload.credit_outputs.first() - && let Ok(addr) = - Address::from_script(&credit_output.script_pubkey, self.network) - && known_addresses.contains(&addr) - { - return true; // Keep this asset lock - } + recovered_count += 1; + total_amount += credit_amount; + tracing::info!( - "Removing asset lock {} - credit address not in wallet", - tx.txid() + "Found unused asset lock (full scan): txid={}, amount={} duffs", + txid, + credit_amount ); - txids_to_remove.push(tx.txid()); - false // Remove this asset lock - }); - - before_count - wallet_guard.unused_asset_locks.len() - }; - - // Also delete from database - for txid in &txids_to_remove { - if let Err(e) = self.db.delete_asset_lock_transaction(txid.as_byte_array()) { - tracing::warn!("Failed to delete asset lock {} from database: {}", txid, e); } } - if removed_count > 0 { - tracing::info!( - "Removed {} asset locks that don't belong to this wallet", - removed_count - ); - } - tracing::info!( "Asset lock search complete. Found {} unused asset locks worth {} duffs", recovered_count, diff --git a/src/backend_task/core/refresh_single_key_wallet_info.rs b/src/backend_task/core/refresh_single_key_wallet_info.rs index b42813af3..ebb1f73b5 100644 --- a/src/backend_task/core/refresh_single_key_wallet_info.rs +++ b/src/backend_task/core/refresh_single_key_wallet_info.rs @@ -52,23 +52,13 @@ impl AppContext { wallet_guard.update_balances(total_balance, 0, total_balance); } - if let Err(e) = - self.db - .update_single_key_wallet_balances(&key_hash, total_balance, 0, total_balance) - { - tracing::warn!(error = %e, "Failed to persist single key wallet balances"); - } - - for (outpoint, tx_out) in &utxo_map { - self.db.insert_utxo( - outpoint.txid.as_ref(), - outpoint.vout, - &address, - tx_out.value, - &tx_out.script_pubkey.to_bytes(), - self.network, - )?; - } + self.db.persist_single_key_wallet_refresh( + &key_hash, + total_balance, + &utxo_map, + &address, + self.network, + )?; Ok(()) } diff --git a/src/backend_task/core/refresh_wallet_info.rs b/src/backend_task/core/refresh_wallet_info.rs index 085b0a67e..2575810cd 100644 --- a/src/backend_task/core/refresh_wallet_info.rs +++ b/src/backend_task/core/refresh_wallet_info.rs @@ -6,6 +6,8 @@ use dash_sdk::dashcore_rpc::RpcApi; use dash_sdk::dashcore_rpc::json::GetTransactionResultDetailCategory; use dash_sdk::dpp::dashcore::hashes::Hash; use dash_sdk::dpp::dashcore::{Address, BlockHash, OutPoint, Transaction, TxOut, Txid}; +use dash_sdk::dpp::key_wallet::WalletCoreBalance; +use crate::platform_wallet_bridge::CoreAddressInfo; use std::collections::{HashMap, HashSet}; use std::sync::{Arc, RwLock}; @@ -21,17 +23,32 @@ impl AppContext { // Step 1: Collect data from wallet with brief read lock let (addresses, asset_lock_txs, seed_hash, core_wallet_name) = { let wallet_guard = wallet.read()?; - let addrs = wallet_guard - .known_addresses - .iter() - .filter(|(_, path)| !path.is_platform_payment(self.network)) - .map(|(addr, _)| addr.clone()) - .collect::>(); + + // Read addresses from PlatformWallet (canonical source). + // Locked wallets (no PlatformWallet) have no addresses — return empty. + // Exclude platform payment addresses since those are not tracked by Core. + let addrs = if let Some(pw) = wallet_guard.platform_wallet.as_ref() { + let info = pw.state_blocking(); + CoreAddressInfo::all_from_wallet_info(info.managed_state.wallet_info()) + .into_iter() + .filter(|a| !a.derivation_path.is_platform_payment(self.network)) + .map(|a| a.address) + .collect::>() + } else { + Vec::new() + }; + let asset_locks: Vec = wallet_guard - .unused_asset_locks - .iter() - .map(|(tx, _, _, _, _)| tx.clone()) - .collect(); + .platform_wallet + .as_ref() + .map(|pw| { + pw.asset_locks() + .list_tracked_locks_blocking() + .into_iter() + .map(|lock| lock.transaction) + .collect() + }) + .unwrap_or_default(); let seed = wallet_guard.seed_hash(); let cwn = wallet_guard.core_wallet_name.clone(); (addrs, asset_locks, seed, cwn) @@ -75,37 +92,13 @@ impl AppContext { map }; - // Step 4: Calculate balances from UTXOs (no lock needed) - let mut address_balances: HashMap = HashMap::new(); - for tx_out in utxo_map.values() { - if let Ok(address) = Address::from_script(&tx_out.script_pubkey, self.network) { - *address_balances.entry(address).or_insert(0) += tx_out.value; - } - } + // Per-address balances and total-received are now persisted via the + // changeset path. Only total_balance is computed here for in-memory state. - // Step 5: Fetch total received for each address from Core RPC (no wallet lock) - let mut total_received_map: HashMap = HashMap::new(); - { - for address in &addresses { - match client.get_received_by_address(address, None) { - Ok(amount) => { - total_received_map.insert(address.clone(), amount.to_sat()); - } - Err(e) => { - tracing::debug!( - ?e, - address = %address, - "get_received_by_address failed" - ); - } - } - } - } - - // Step 6: Load transaction history from Core RPC (skip in SPV mode) + // Step 4: Load transaction history from Core RPC (skip in SPV mode) // None = skip update (SPV mode or RPC failure), Some = replace DB/in-memory state let mut tx_truncated = false; - let rpc_transactions: Option> = if is_spv_mode { + let _rpc_transactions: Option> = if is_spv_mode { None } else { let wallet_address_set: HashSet
= addresses.iter().cloned().collect(); @@ -269,112 +262,40 @@ impl AppContext { .collect() }; - // Step 8: Insert UTXOs into database (no wallet lock needed) - for (outpoint, tx_out) in &utxo_map { - if let Ok(address) = Address::from_script(&tx_out.script_pubkey, self.network) { - self.db.insert_utxo( - outpoint.txid.as_ref(), - outpoint.vout, - &address, - tx_out.value, - &tx_out.script_pubkey.to_bytes(), - self.network, - )?; - } - } + // UTXOs, transactions, and balances are now persisted via the + // changeset path (auto-flushed via FlushStrategy::Immediate). Only + // asset lock cleanup and in-memory state updates remain as direct + // operations. - // Step 9: Delete stale asset locks from database (no wallet lock needed) + // Step 8: Delete stale asset locks from database (no wallet lock needed) for txid in &stale_txids { if let Err(e) = self.db.delete_asset_lock_transaction(txid.as_byte_array()) { tracing::warn!("Failed to delete stale asset lock from database: {}", e); } } - // Step 10: Calculate total balance (no lock needed) + // Step 9: Calculate total balance (no lock needed) let total_balance: u64 = utxo_map.values().map(|tx_out| tx_out.value).sum(); - // Step 11: Persist transactions to database BEFORE the write lock so we - // can move (not clone) rpc_transactions into the wallet afterwards. - if let Some(ref txs) = rpc_transactions { - self.db - .replace_wallet_transactions(&seed_hash, &self.network, txs)?; - } - - // Step 12: Update wallet IN-MEMORY state only (brief write lock, no I/O) - let (changed_balances, changed_total_received): (Vec<_>, Vec<_>) = { - let mut wallet_guard = wallet.write()?; - - let new_outpoints: std::collections::HashSet<_> = utxo_map.keys().cloned().collect(); - - for utxos in wallet_guard.utxos.values_mut() { - utxos.retain(|outpoint, _| new_outpoints.contains(outpoint)); - } - wallet_guard.utxos.retain(|_, utxos| !utxos.is_empty()); - - for (outpoint, tx_out) in &utxo_map { - if let Ok(address) = Address::from_script(&tx_out.script_pubkey, self.network) { - wallet_guard - .utxos - .entry(address) - .or_default() - .insert(*outpoint, tx_out.clone()); - } - } - - let mut balance_changes = Vec::new(); - for address in &addresses { - let balance = address_balances.get(address).cloned().unwrap_or(0); - let current = wallet_guard.address_balances.get(address).cloned(); - if current != Some(balance) { - wallet_guard - .address_balances - .insert(address.clone(), balance); - balance_changes.push((address.clone(), balance)); - } - } - - let mut received_changes = Vec::new(); - for (address, total_received) in &total_received_map { - let current = wallet_guard.address_total_received.get(address).cloned(); - if current != Some(*total_received) { - wallet_guard - .address_total_received - .insert(address.clone(), *total_received); - received_changes.push((address.clone(), *total_received)); - } - } - - if !stale_txids.is_empty() { - let stale_count = stale_txids.len(); - wallet_guard - .unused_asset_locks - .retain(|(tx, _, _, _, _)| !stale_txids.contains(&tx.txid())); - tracing::info!("Removed {} stale asset locks", stale_count); - } - - if let Some(txs) = rpc_transactions { - wallet_guard.set_transactions(txs); - } - - wallet_guard.update_spv_balances(total_balance, 0, total_balance); - - (balance_changes, received_changes) - }; - - // Step 13: Persist remaining changes to database (no wallet lock needed) - for (address, balance) in &changed_balances { - self.db - .update_address_balance(&seed_hash, address, *balance)?; - } - - for (address, total_received) in &changed_total_received { - self.db - .update_address_total_received(&seed_hash, address, *total_received)?; + // Step 10: Update wallet IN-MEMORY state only (brief write lock, no I/O) + // + // Sync balance to PlatformWallet's ManagedWalletInfo so it stays + // current and can serve as the canonical read source. + // Uses try_state_mut() because we are in a blocking context + // (spawn_blocking) where awaiting is not possible. + if let Some(pw) = self.get_platform_wallet(&seed_hash) + && let Some(mut pw_info) = pw.try_state_mut() + { + pw_info.managed_state.wallet_info_mut().balance = WalletCoreBalance::new( + total_balance, // spendable + 0, // unconfirmed + 0, // immature + 0, // locked + ); + // Wallet changes are auto-flushed via FlushStrategy::Immediate + // when queued by the platform wallet. } - self.db - .update_wallet_balances(&seed_hash, total_balance, 0, total_balance)?; - let warning = if tx_truncated { Some( "Transaction history may be incomplete. The most recent transactions are shown." 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 a8279102e..51a2e1757 100644 --- a/src/backend_task/core/send_single_key_wallet_payment.rs +++ b/src/backend_task/core/send_single_key_wallet_payment.rs @@ -199,25 +199,20 @@ impl AppContext { wallet_guard.update_balances(new_balance, 0, new_balance); } - let key_hash = wallet.read()?.key_hash; - - for (outpoint, _) in &selected_utxos { - if let Err(e) = self.db.drop_utxo(outpoint, &self.network.to_string()) { - tracing::warn!( - "Failed to remove spent UTXO {:?} from database: {}", - outpoint, - e - ); - } - } + let (key_hash, balance) = { + let guard = wallet.read()?; + (guard.key_hash, guard.total_balance) + }; - let balance = wallet.read()?.total_balance; - if let Err(e) = self - .db - .update_single_key_wallet_balances(&key_hash, balance, 0, balance) - { + let spent_outpoints: Vec<_> = selected_utxos.iter().map(|(op, _)| *op).collect(); + if let Err(e) = self.db.persist_single_key_wallet_spend( + &key_hash, + &spent_outpoints, + balance, + &self.network.to_string(), + ) { tracing::warn!( - "Failed to update single key wallet balances in database: {}", + "Failed to persist single key wallet spend to database: {}", e ); } diff --git a/src/backend_task/dashpay.rs b/src/backend_task/dashpay.rs index 6a88ea55d..df124e6b4 100644 --- a/src/backend_task/dashpay.rs +++ b/src/backend_task/dashpay.rs @@ -10,11 +10,9 @@ pub mod avatar_processing; pub mod contact_info; pub mod contact_requests; pub mod contacts; -pub mod dip14_derivation; pub mod encryption; pub mod encryption_tests; pub mod errors; -pub mod hd_derivation; pub mod incoming_payments; pub mod payments; pub mod profile; diff --git a/src/backend_task/dashpay/auto_accept_proof.rs b/src/backend_task/dashpay/auto_accept_proof.rs index 76c82c428..ed8c7d112 100644 --- a/src/backend_task/dashpay/auto_accept_proof.rs +++ b/src/backend_task/dashpay/auto_accept_proof.rs @@ -1,13 +1,14 @@ -use super::hd_derivation::derive_auto_accept_key; use crate::model::qualified_identity::QualifiedIdentity; use dash_sdk::dpp::dashcore::secp256k1::{Message, Secp256k1, SecretKey}; 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::key_wallet::bip32::{DerivationPath, ExtendedPrivKey}; use dash_sdk::platform::Identifier; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::HashSet; +use std::str::FromStr; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct AutoAcceptProofData { @@ -119,6 +120,33 @@ impl AutoAcceptProofData { } } +/// DashPay auto-accept feature index per DIP-15. +const DASHPAY_AUTO_ACCEPT_FEATURE: u32 = 16; + +/// Derive auto-accept proof key according to DIP-0015. +/// Path: m/9'/5'/16'/timestamp' +/// +/// This uses the provided key bytes as a master seed for derivation, which is +/// appropriate when the caller passes wallet-derived private key material. +fn derive_auto_accept_key( + master_seed: &[u8], + network: dash_sdk::dpp::dashcore::Network, + timestamp: u32, +) -> Result { + let master_xprv = ExtendedPrivKey::new_master(network, master_seed) + .map_err(|e| format!("Failed to create master key: {}", e))?; + + let path = DerivationPath::from_str(&format!( + "m/9'/5'/{}'/{}'", + DASHPAY_AUTO_ACCEPT_FEATURE, timestamp + )) + .map_err(|e| format!("Invalid derivation path: {}", e))?; + + master_xprv + .derive_priv(&Secp256k1::new(), &path) + .map_err(|e| format!("Failed to derive auto-accept key: {}", e)) +} + /// Generate an auto-accept proof for QR code sharing /// /// According to DIP-0015, the autoAcceptProof is a signature that allows the recipient diff --git a/src/backend_task/dashpay/contact_requests.rs b/src/backend_task/dashpay/contact_requests.rs index 6ae6d1f10..cd5ac395e 100644 --- a/src/backend_task/dashpay/contact_requests.rs +++ b/src/backend_task/dashpay/contact_requests.rs @@ -1,11 +1,4 @@ -use super::encryption::{ - encrypt_account_label, encrypt_extended_public_key, generate_ecdh_shared_key, -}; use super::errors::DashPayError; -use super::hd_derivation::{ - calculate_account_reference, derive_dashpay_incoming_xpub, generate_contact_xpub_data, -}; -use super::validation::validate_contact_request_before_send; use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::dashpay::auto_accept_proof::{ AutoAcceptProofData, create_auto_accept_proof_bytes_with_key, @@ -13,22 +6,20 @@ use crate::backend_task::dashpay::auto_accept_proof::{ use crate::backend_task::error::TaskError; use crate::context::AppContext; use crate::model::qualified_identity::QualifiedIdentity; -use bip39::rand::{SeedableRng, rngs::StdRng}; use dash_sdk::Sdk; -use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; -use dash_sdk::dpp::document::{Document as DppDocument, DocumentV0, DocumentV0Getters}; +use dash_sdk::dpp::document::DocumentV0Getters; +use dash_sdk::dpp::identity::Identity; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; -use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; -use dash_sdk::dpp::identity::{Identity, KeyType, Purpose, SecurityLevel}; +use dash_sdk::dpp::platform_value::Value; use dash_sdk::dpp::platform_value::string_encoding::Encoding; -use dash_sdk::dpp::platform_value::{Bytes32, Value}; use dash_sdk::drive::query::{OrderClause, WhereClause, WhereOperator}; -use dash_sdk::platform::documents::transitions::DocumentCreateTransitionBuilder; use dash_sdk::platform::{ - Document, DocumentQuery, Fetch, FetchMany, FetchUnproved, Identifier, IdentityPublicKey, + Document, DocumentQuery, Fetch, FetchMany, Identifier, IdentityPublicKey, +}; +use platform_wallet::changeset::changeset::{ + ContactChangeSet, ContactRequestEntry, PlatformWalletChangeSet, }; -use dash_sdk::query_types::{CurrentQuorumsInfo, NoParamQuery}; -use std::collections::{BTreeMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::sync::Arc; pub async fn load_contact_requests( @@ -158,11 +149,18 @@ pub async fn load_contact_requests( Ok(BackendTaskSuccessResult::DashPayContactRequests { incoming, outgoing }) } +/// Send a contact request, delegating ECDH, xpub encryption, document +/// construction and broadcast to the platform-wallet's `DashPayWallet`. +/// +/// The caller is still responsible for: +/// - Resolving usernames / identity IDs +/// - Checking for duplicate requests on Platform +/// - Returning the appropriate `BackendTaskSuccessResult` pub async fn send_contact_request( app_context: &Arc, sdk: &Sdk, identity: QualifiedIdentity, - signing_key: IdentityPublicKey, + _signing_key: IdentityPublicKey, to_username_or_id: String, account_label: Option, ) -> Result { @@ -170,7 +168,7 @@ pub async fn send_contact_request( app_context, sdk, identity, - signing_key, + _signing_key, to_username_or_id, account_label, None, @@ -182,7 +180,7 @@ pub async fn send_contact_request_with_proof( app_context: &Arc, sdk: &Sdk, identity: QualifiedIdentity, - signing_key: IdentityPublicKey, + _signing_key: IdentityPublicKey, to_username_or_id: String, account_label: Option, qr_auto_accept: Option, @@ -243,187 +241,8 @@ pub async fn send_contact_request_with_proof( .into()); } - // Step 3: Get key indices for ECDH - // Per DIP-11/DIP-15: Use ENCRYPTION key for sender (to encrypt outgoing), - // DECRYPTION key for recipient (they will decrypt incoming) - // Note: signing_key is an AUTHENTICATION key used to sign the state transition - // We need a separate ENCRYPTION key for ECDH - let sender_encryption_key = identity - .identity - .get_first_public_key_matching( - Purpose::ENCRYPTION, - HashSet::from([SecurityLevel::MEDIUM]), - HashSet::from([KeyType::ECDSA_SECP256K1]), - false, - ) - .ok_or_else(|| TaskError::DashPay(DashPayError::MissingEncryptionKey))?; - - // Find a recipient DECRYPTION key that supports ECDH (must be ECDSA_SECP256K1) - // Platform enforces MEDIUM security level for ENCRYPTION/DECRYPTION keys - let recipient_key = to_identity - .get_first_public_key_matching( - Purpose::DECRYPTION, - HashSet::from([SecurityLevel::MEDIUM]), - HashSet::from([KeyType::ECDSA_SECP256K1]), - false, - ) - .ok_or_else(|| TaskError::DashPay(DashPayError::MissingDecryptionKey))?; - - // Step 4: Generate ECDH shared key and encrypt data - let wallets: Vec<_> = identity.associated_wallets.values().cloned().collect(); - let sender_private_key = identity - .private_keys - .get_resolve( - &( - crate::model::qualified_identity::PrivateKeyTarget::PrivateKeyOnMainIdentity, - sender_encryption_key.id(), - ), - &wallets, - identity.network, - ) - .map_err(|e| TaskError::EncryptionError { - detail: format!("Error resolving ENCRYPTION private key: {}", e), - })? - .map(|(_, private_key)| private_key) - .ok_or_else(|| { - TaskError::DashPay(DashPayError::PrivateKeyResolution { - key_purpose: "ENCRYPTION".to_string(), - reason: "Private key not loaded into Dash Evo Tool".to_string(), - }) - })?; - - let shared_key = generate_ecdh_shared_key(&sender_private_key, recipient_key) - .map_err(|e| TaskError::EncryptionError { detail: e })?; - - // Generate extended public key for this contact using proper HD derivation - // For now, use the sender's private key as seed material - // In production, this would derive from the wallet's HD seed/mnemonic - let wallet_seed = sender_private_key; - - // Get the network from app context - let network = app_context.network; - - // Use account 0 for now (could be made configurable) - let account_index = 0u32; - - // Generate the extended public key data for this contact relationship - let (parent_fingerprint, chain_code, contact_public_key) = generate_contact_xpub_data( - &wallet_seed, - network, - account_index, - &identity.identity.id(), - &to_identity_id, - ) - .map_err(|e| TaskError::EncryptionError { detail: e })?; - - // Also derive the full xpub for account reference calculation per DIP-0015 - let contact_xpub = derive_dashpay_incoming_xpub( - &wallet_seed, - network, - account_index, - &identity.identity.id(), - &to_identity_id, - ) - .map_err(|e| TaskError::EncryptionError { detail: e })?; - - // Calculate account reference per DIP-0015 (ASK-based shortening) - // Version 0 is the current version - let account_reference = calculate_account_reference( - &sender_private_key, - &contact_xpub, - account_index, - 0, // version - ); - - let encrypted_public_key = encrypt_extended_public_key( - parent_fingerprint, - chain_code, - contact_public_key, - &shared_key, - ) - .map_err(|e| TaskError::EncryptionError { detail: e })?; - - // Step 5: Get the current core chain height for synchronization - let (core_height, current_height_for_validation) = - match CurrentQuorumsInfo::fetch_unproved(sdk, NoParamQuery {}).await { - Ok(Some(quorum_info)) => ( - quorum_info.last_core_block_height, - Some(quorum_info.last_core_block_height), - ), - Ok(None) => { - (0u32, None) // Fallback if no quorum info available - } - Err(_e) => { - (0u32, None) // Fallback on error - } - }; - - // Step 5.5: Validate the contact request before proceeding - // Note: We validate the ENCRYPTION key (used for ECDH), not the signing key - let validation = validate_contact_request_before_send( - sdk, - &identity, - sender_encryption_key.id(), - to_identity.id(), - recipient_key.id(), - account_reference, - core_height, - current_height_for_validation, - ) - .await - .map_err(|e| DashPayError::ValidationFailed { - errors: vec![e.to_string()], - })?; - - // Check if validation passed - if !validation.is_valid { - return Err(DashPayError::ValidationFailed { - errors: validation.errors.clone(), - } - .into()); - } - - // Log any warnings - for _warning in &validation.warnings {} - - // Step 6: Create contact request document - let mut properties = BTreeMap::new(); - properties.insert( - "toUserId".to_string(), - Value::Identifier(to_identity_id.to_buffer()), - ); - properties.insert( - "senderKeyIndex".to_string(), - Value::U32(sender_encryption_key.id()), - ); - properties.insert( - "recipientKeyIndex".to_string(), - Value::U32(recipient_key.id()), - ); - // Account reference calculated per DIP-0015 (ASK-based shortening) - properties.insert( - "accountReference".to_string(), - Value::U32(account_reference), - ); - properties.insert( - "encryptedPublicKey".to_string(), - Value::Bytes(encrypted_public_key), - ); - - // Note: $coreHeightCreatedAt is handled automatically by the platform - - // Add encrypted account label if provided - if let Some(label) = account_label { - let encrypted_label = encrypt_account_label(&label, &shared_key) - .map_err(|e| TaskError::EncryptionError { detail: e })?; - properties.insert( - "encryptedAccountLabel".to_string(), - Value::Bytes(encrypted_label), - ); - } - - // If QR auto-accept data is provided, create the proof bytes now to match the final accountReference - if let Some(qr) = qr_auto_accept { + // Step 3: Build auto-accept proof bytes if QR data was provided + let auto_accept_proof = if let Some(qr) = qr_auto_accept { // Ensure the QR target matches the resolved recipient if qr.identity_id != to_identity_id { return Err(DashPayError::InvalidQrCode { @@ -431,86 +250,75 @@ pub async fn send_contact_request_with_proof( } .into()); } + // NOTE: Behavior change from old code. Previously account_reference was + // calculated via DIP-15 calculate_account_reference(). Now platform-wallet's + // send_contact_request() uses account_index (0) as account_reference in + // the document. The auto-accept proof must use the same value to match. + // QR auto-accept codes are session-scoped, so old QR codes from previous + // app versions won't be valid anyway (they expire). let proof = create_auto_accept_proof_bytes_with_key( qr.expires_at, &qr.proof_key, &identity.identity.id(), &to_identity_id, - account_reference, + 0, // Must match platform-wallet's account_reference (= account_index = 0) ) .map_err(|e| TaskError::EncryptionError { detail: e })?; tracing::debug!( "Including autoAcceptProof in contact request ({} bytes)", proof.len() ); - properties.insert("autoAcceptProof".to_string(), Value::Bytes(proof)); - } - // If no proof, don't include the field at all (schema requires 38-102 bytes if present) - - // Generate random entropy for the document transition - let mut rng = StdRng::from_entropy(); - let entropy = Bytes32::random_with_rng(&mut rng); - - // Generate deterministic document ID based on entropy - let document_id = Document::generate_document_id_v0( - &dashpay_contract.id(), - &identity.identity.id(), - "contactRequest", - entropy.as_slice(), - ); - - // Create the document - let document = DppDocument::V0(DocumentV0 { - id: document_id, - owner_id: identity.identity.id(), - creator_id: None, - properties, - revision: Some(1), - created_at: None, - updated_at: None, - transferred_at: None, - created_at_block_height: None, - updated_at_block_height: None, - transferred_at_block_height: None, - created_at_core_block_height: None, - updated_at_core_block_height: None, - transferred_at_core_block_height: None, - }); + Some(proof) + } else { + None + }; - // Step 7: Submit the contact request - // Use the selected signing key - let identity_key = &signing_key; - - let mut builder = DocumentCreateTransitionBuilder::new( - dashpay_contract, - "contactRequest".to_string(), - document, - entropy - .as_slice() - .try_into() - .expect("entropy should be 32 bytes"), - ); + // Step 4: Delegate to platform-wallet's DashPayWallet + let platform_wallet = app_context.platform_wallet_for_identity(&identity)?; + let sender_id = identity.identity.id(); - // Add state transition options if available - let maybe_options = app_context.state_transition_options(); - if let Some(options) = maybe_options { - builder = builder.with_state_transition_creation_options(options); - } + let contact_request = platform_wallet + .dashpay() + .send_contact_request( + &sender_id, + &to_identity_id, + account_label, + auto_accept_proof, + ) + .await + .map_err(|e| TaskError::PlatformWallet { + source: Box::new(e), + })?; - let result = sdk - .document_create(builder, identity_key, &identity) - .await?; - - // Log the proof-verified document for audit trail - match result { - dash_sdk::platform::documents::transitions::DocumentCreateResult::Document(doc) => { - tracing::info!( - "Contact request created: doc_id={}, revision={:?}", - doc.id(), - doc.revision() - ); - } - } + // Step 5: Stage a PlatformWalletChangeSet capturing the sent contact request + // and the new DashPay contact account, then persist so the delta is + // durably stored. + // + // `register_contact_account()` (called inside `send_contact_request`) + // creates a DashpayReceivingFunds managed account in memory. Record + // its initial `last_revealed` index so the key-wallet changeset + // reflects the full delta. + let kw_account_cs = dash_sdk::dpp::key_wallet::changeset::AccountChangeSet { + last_revealed: BTreeMap::from([(0u32, 0u32)]), + ..Default::default() + }; + let changeset = PlatformWalletChangeSet { + wallet: Some(dash_sdk::dpp::key_wallet::changeset::WalletChangeSet { + accounts: Some(kw_account_cs), + ..Default::default() + }), + contacts: Some(ContactChangeSet { + sent_requests: BTreeMap::from([( + (sender_id, to_identity_id), + ContactRequestEntry { + request: contact_request, + }, + )]), + ..Default::default() + }), + ..Default::default() + }; + platform_wallet.queue_persist(changeset); Ok(BackendTaskSuccessResult::DashPayContactRequestSent( to_username_or_id.to_string(), @@ -575,18 +383,17 @@ async fn resolve_username_to_identity(sdk: &Sdk, username: &str) -> Result, sdk: &Sdk, identity: QualifiedIdentity, request_id: Identifier, ) -> Result { - // According to DashPay DIP, accepting means sending a contact request back - // First, we need to fetch the incoming contact request to get the sender's identity - + // Fetch the incoming contact request document to identify the sender let dashpay_contract = app_context.dashpay_contract.clone(); - // Fetch the specific contact request document by creating a query with its ID let query = DocumentQuery::new(dashpay_contract.clone(), "contactRequest").map_err(|e| { DashPayError::QueryCreation { query_target: "DashPay contactRequest", @@ -630,35 +437,58 @@ pub async fn accept_contact_request( )); } - // Get an AUTHENTICATION key for signing the state transition - // Platform requires CRITICAL or HIGH security level for document creation - let signing_key = identity - .identity - .get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL, SecurityLevel::HIGH]), - KeyType::all_key_types().into(), - false, + // Delegate to platform-wallet: send a reciprocal contact request + let platform_wallet = app_context.platform_wallet_for_identity(&identity)?; + let our_identity_id = identity.identity.id(); + + let contact_request = platform_wallet + .dashpay() + .send_contact_request( + &our_identity_id, + &from_identity_id, + Some("Accepted contact".to_string()), + None, ) - .ok_or_else(|| TaskError::DashPay(DashPayError::MissingAuthenticationKey))? - .clone(); + .await + .map_err(|e| TaskError::PlatformWallet { + source: Box::new(e), + })?; - let result = send_contact_request( - app_context, - sdk, - identity, - signing_key, - from_identity_id.to_string(Encoding::Base58), - Some("Accepted contact".to_string()), - ) - .await; + // Stage a PlatformWalletChangeSet capturing the reciprocal sent request, + // the newly established contact, and the new DashPay contact account. + // + // `register_contact_account()` (called inside `send_contact_request`) creates + // a DashpayReceivingFunds managed account in memory. Record its initial + // `last_revealed` index so the key-wallet changeset reflects the full delta. + let mut established = BTreeSet::new(); + established.insert((our_identity_id, from_identity_id)); + + let kw_account_cs = dash_sdk::dpp::key_wallet::changeset::AccountChangeSet { + last_revealed: BTreeMap::from([(0u32, 0u32)]), + ..Default::default() + }; + let changeset = PlatformWalletChangeSet { + wallet: Some(dash_sdk::dpp::key_wallet::changeset::WalletChangeSet { + accounts: Some(kw_account_cs), + ..Default::default() + }), + contacts: Some(ContactChangeSet { + sent_requests: BTreeMap::from([( + (our_identity_id, from_identity_id), + ContactRequestEntry { + request: contact_request, + }, + )]), + established, + ..Default::default() + }), + ..Default::default() + }; + platform_wallet.queue_persist(changeset); - match result { - Ok(_) => Ok(BackendTaskSuccessResult::DashPayContactRequestAccepted( - request_id, - )), - Err(e) => Err(e), - } + Ok(BackendTaskSuccessResult::DashPayContactRequestAccepted( + request_id, + )) } pub async fn reject_contact_request( diff --git a/src/backend_task/dashpay/dip14_derivation.rs b/src/backend_task/dashpay/dip14_derivation.rs deleted file mode 100644 index fbe99ecfe..000000000 --- a/src/backend_task/dashpay/dip14_derivation.rs +++ /dev/null @@ -1,377 +0,0 @@ -use dash_sdk::dpp::dashcore::Network; -use dash_sdk::dpp::dashcore::hashes::hmac::{Hmac, HmacEngine}; -use dash_sdk::dpp::dashcore::hashes::sha512; -/// DIP-14 compliant 256-bit HD key derivation implementation -/// -/// This module implements Extended Key Derivation using 256-bit Unsigned Integers -/// as specified in DIP-0014 for DashPay contact relationships. -use dash_sdk::dpp::dashcore::hashes::{Hash, HashEngine}; -use dash_sdk::dpp::dashcore::secp256k1::{PublicKey, Secp256k1, SecretKey}; -use dash_sdk::dpp::key_wallet::bip32::{ChainCode, ExtendedPrivKey, ExtendedPubKey, Fingerprint}; -use dash_sdk::platform::Identifier; - -/// Perform DIP-14 compliant 256-bit child key derivation for private keys -/// -/// This implements CKDpriv256 as specified in DIP-0014: -/// - For indices < 2^32, uses standard BIP32 derivation for compatibility -/// - For indices >= 2^32, uses 256-bit derivation with ser_256(i) -pub fn ckd_priv_256( - parent_key: &ExtendedPrivKey, - index: &[u8; 32], // 256-bit index - hardened: bool, -) -> Result { - let secp = Secp256k1::new(); - - // Check if this is a compatibility mode derivation (index < 2^32) - let is_compatibility_mode = is_index_less_than_2_32(index); - - // Prepare HMAC data based on the derivation type - let mut hmac_engine = HmacEngine::::new(&parent_key.chain_code.to_bytes()); - - if hardened { - // Hardened derivation: 0x00 || ser_256(k_par) || ser(i) - hmac_engine.input(&[0x00]); - hmac_engine.input(&parent_key.private_key.secret_bytes()); - - if is_compatibility_mode { - // Use ser_32(i) for compatibility - hmac_engine.input(&index[28..32]); - } else { - // Use ser_256(i) for full 256-bit - hmac_engine.input(index); - } - } else { - // Non-hardened derivation: ser_P(point(k_par)) || ser(i) - let parent_pubkey = parent_key.private_key.public_key(&secp); - hmac_engine.input(&parent_pubkey.serialize()); - - if is_compatibility_mode { - // Use ser_32(i) for compatibility - hmac_engine.input(&index[28..32]); - } else { - // Use ser_256(i) for full 256-bit - hmac_engine.input(index); - } - } - - let hmac_result = Hmac::::from_engine(hmac_engine); - let hmac_bytes = hmac_result.to_byte_array(); - - // Split into I_L (first 32 bytes) and I_R (last 32 bytes) - let (i_l, i_r) = hmac_bytes.split_at(32); - - // Parse I_L as a private key and add to parent key - let i_l_key = SecretKey::from_slice(i_l) - .map_err(|e| format!("Failed to parse I_L as secret key: {}", e))?; - - // k_i = parse_256(I_L) + k_par (mod n) - let child_key = parent_key - .private_key - .add_tweak(&i_l_key.into()) - .map_err(|e| format!("Failed to add tweak to parent key: {}", e))?; - - // Chain code is I_R (32 bytes) - let mut chain_code_bytes = [0u8; 32]; - chain_code_bytes.copy_from_slice(i_r); - let child_chain_code = ChainCode::from(chain_code_bytes); - - // Calculate child fingerprint - let parent_pubkey = parent_key.private_key.public_key(&secp); - let parent_fingerprint = calculate_fingerprint(&parent_pubkey); - - // Create the child extended private key - Ok(ExtendedPrivKey { - network: parent_key.network, - depth: parent_key.depth + 1, - parent_fingerprint, - child_number: index_to_child_number(index, hardened)?, - private_key: child_key, - chain_code: child_chain_code, - }) -} - -/// Perform DIP-14 compliant 256-bit child key derivation for public keys -/// -/// This implements CKDpub256 as specified in DIP-0014: -/// - Only works for non-hardened derivation -/// - For indices < 2^32, uses standard BIP32 derivation for compatibility -/// - For indices >= 2^32, uses 256-bit derivation with ser_256(i) -pub fn ckd_pub_256( - parent_key: &ExtendedPubKey, - index: &[u8; 32], // 256-bit index - hardened: bool, -) -> Result { - if hardened { - return Err("Cannot derive hardened child from extended public key".to_string()); - } - - let secp = Secp256k1::new(); - - // Check if this is a compatibility mode derivation (index < 2^32) - let is_compatibility_mode = is_index_less_than_2_32(index); - - // Prepare HMAC data - let mut hmac_engine = HmacEngine::::new(&parent_key.chain_code.to_bytes()); - - // Non-hardened derivation: ser_P(K_par) || ser(i) - hmac_engine.input(&parent_key.public_key.serialize()); - - if is_compatibility_mode { - // Use ser_32(i) for compatibility - hmac_engine.input(&index[28..32]); - } else { - // Use ser_256(i) for full 256-bit - hmac_engine.input(index); - } - - let hmac_result = Hmac::::from_engine(hmac_engine); - let hmac_bytes = hmac_result.to_byte_array(); - - // Split into I_L (first 32 bytes) and I_R (last 32 bytes) - let (i_l, i_r) = hmac_bytes.split_at(32); - - // Parse I_L as a secret key for the tweak - let i_l_key = SecretKey::from_slice(i_l) - .map_err(|e| format!("Failed to parse I_L as secret key: {}", e))?; - - // K_i = point(parse_256(I_L)) + K_par - let child_pubkey = parent_key - .public_key - .add_exp_tweak(&secp, &i_l_key.into()) - .map_err(|e| format!("Failed to add tweak to parent public key: {}", e))?; - - // Chain code is I_R (32 bytes) - let mut chain_code_bytes = [0u8; 32]; - chain_code_bytes.copy_from_slice(i_r); - let child_chain_code = ChainCode::from(chain_code_bytes); - - // Create the child extended public key - Ok(ExtendedPubKey { - network: parent_key.network, - depth: parent_key.depth + 1, - parent_fingerprint: parent_key.parent_fingerprint, - child_number: index_to_child_number(index, false)?, - public_key: child_pubkey, - chain_code: child_chain_code, - }) -} - -/// Derive DashPay incoming funds extended public key using DIP-14 compliant derivation -/// Path: m/9'/5'/15'/account'/(sender_id)/(recipient_id) -pub fn derive_dashpay_incoming_xpub_dip14( - master_seed: &[u8], - network: Network, - account: u32, - sender_id: &Identifier, - recipient_id: &Identifier, -) -> Result { - use dash_sdk::dpp::key_wallet::bip32::DerivationPath; - use std::str::FromStr; - - // Create extended private key from seed - let master_xprv = ExtendedPrivKey::new_master(network, master_seed) - .map_err(|e| format!("Failed to create master key: {}", e))?; - - // Build derivation path for the base: m/9'/5'/15'/account' - let base_path = DerivationPath::from_str(&format!("m/9'/5'/15'/{}'", account)) - .map_err(|e| format!("Invalid derivation path: {}", e))?; - - // Derive to the account level using standard BIP32 - let secp = Secp256k1::new(); - let account_xprv = master_xprv - .derive_priv(&secp, &base_path) - .map_err(|e| format!("Failed to derive account key: {}", e))?; - - // Now use DIP-14 256-bit derivation for the identity levels - // Derive: account_key/(sender_id) - let sender_index = identifier_to_256bit_index(sender_id); - let sender_level = ckd_priv_256(&account_xprv, &sender_index, false)?; - - // Derive: sender_level/(recipient_id) - let recipient_index = identifier_to_256bit_index(recipient_id); - let contact_xprv = ckd_priv_256(&sender_level, &recipient_index, false)?; - - // Convert to extended public key - Ok(ExtendedPubKey::from_priv(&secp, &contact_xprv)) -} - -/// Convert an Identifier to a 256-bit index for DIP-14 derivation -fn identifier_to_256bit_index(id: &Identifier) -> [u8; 32] { - let mut index = [0u8; 32]; - index.copy_from_slice(&id.to_buffer()); - index -} - -/// Check if a 256-bit index is less than 2^32 (compatibility mode) -fn is_index_less_than_2_32(index: &[u8; 32]) -> bool { - // Check if the first 28 bytes are all zeros - index[0..28].iter().all(|&b| b == 0) -} - -/// Convert a 256-bit index to a ChildNumber for storage -/// This is a simplified representation since ChildNumber only supports 31-bit indices -fn index_to_child_number( - index: &[u8; 32], - hardened: bool, -) -> Result { - use dash_sdk::dpp::key_wallet::bip32::ChildNumber; - - // For compatibility with existing ChildNumber structure, - // we need to ensure the value fits in 31 bits for normal, or set the hardened bit - // We'll use a hash of the full 256-bit index to get a deterministic 31-bit value - use dash_sdk::dpp::dashcore::hashes::Hash; - use dash_sdk::dpp::dashcore::hashes::sha256; - - let hash = sha256::Hash::hash(index); - let hash_bytes = hash.to_byte_array(); - - // Take first 4 bytes and mask to 31 bits - let mut bytes = [0u8; 4]; - bytes.copy_from_slice(&hash_bytes[0..4]); - let mut num = u32::from_be_bytes(bytes); - - if hardened { - // Set the hardened bit (bit 31) - num |= 0x80000000; - Ok(ChildNumber::from(num)) - } else { - // Clear bit 31 to ensure it's within normal range - num &= 0x7FFFFFFF; - Ok(ChildNumber::from(num)) - } -} - -/// Calculate fingerprint for a public key (first 4 bytes of HASH160) -fn calculate_fingerprint(pubkey: &PublicKey) -> Fingerprint { - use dash_sdk::dpp::dashcore::hashes::hash160; - - let hash = hash160::Hash::hash(&pubkey.serialize()); - let mut fingerprint_bytes = [0u8; 4]; - fingerprint_bytes.copy_from_slice(&hash.to_byte_array()[0..4]); - Fingerprint::from(fingerprint_bytes) -} - -#[cfg(test)] -mod tests { - use super::*; - use dash_sdk::dpp::dashcore::Network; - use hex; - - #[test] - fn test_256bit_index_detection() { - // Test index less than 2^32 - let mut small_index = [0u8; 32]; - small_index[31] = 42; - assert!(is_index_less_than_2_32(&small_index)); - - // Test index >= 2^32 - let mut large_index = [0u8; 32]; - large_index[27] = 1; // Set a bit in the upper bytes - assert!(!is_index_less_than_2_32(&large_index)); - } - - #[test] - fn test_identifier_to_index_conversion() { - let id_bytes = [ - 0x77, 0x5d, 0x38, 0x54, 0xc9, 0x10, 0xb7, 0xde, 0xe4, 0x36, 0x86, 0x9c, 0x47, 0x24, - 0xbe, 0xd2, 0xfe, 0x07, 0x84, 0xe1, 0x98, 0xb8, 0xa3, 0x9f, 0x02, 0xbb, 0xb4, 0x9d, - 0x8e, 0xbc, 0xfc, 0x3b, - ]; - let id = Identifier::from_bytes(&id_bytes).unwrap(); - - let index = identifier_to_256bit_index(&id); - assert_eq!(index, id_bytes); - } - - #[test] - fn test_dip14_derivation_compatibility() { - // Test that derivation with index < 2^32 matches standard BIP32 - let seed = [0x42u8; 64]; - let network = Network::Testnet; - - let master = ExtendedPrivKey::new_master(network, &seed).unwrap(); - - // Test with small index (should use compatibility mode) - let mut small_index = [0u8; 32]; - small_index[31] = 1; - - let child = ckd_priv_256(&master, &small_index, false); - assert!(child.is_ok()); - } - - #[test] - fn test_dip14_test_vector_1() { - // Test Vector 1 from DIP-14 - // Mnemonic: birth kingdom trash renew flavor utility donkey gasp regular alert pave layer - let seed_hex = "b16d3782e714da7c55a397d5f19104cfed7ffa8036ac514509bbb50807f8ac598eeb26f0797bd8cc221a6cbff2168d90a5e9ee025a5bd977977b9eccd97894bb"; - let seed = hex::decode(seed_hex).unwrap(); - let network = Network::Testnet; - - // Test derivation path with 256-bit indices - let index1 = - hex::decode("775d3854c910b7dee436869c4724bed2fe0784e198b8a39f02bbb49d8ebcfc3b") - .unwrap(); - let index2 = - hex::decode("f537439f36d04a15474ff7423e4b904a14373fafb37a41db74c84f1dbb5c89a6") - .unwrap(); - - let master = ExtendedPrivKey::new_master(network, &seed).unwrap(); - - // Derive first level (non-hardened) - let mut index1_array = [0u8; 32]; - index1_array.copy_from_slice(&index1); - let level1 = ckd_priv_256(&master, &index1_array, false).unwrap(); - - // Derive second level (hardened) - let mut index2_array = [0u8; 32]; - index2_array.copy_from_slice(&index2); - let level2 = ckd_priv_256(&level1, &index2_array, true).unwrap(); - - // The test passes if we can derive without errors - // Full validation would require checking against expected key values - assert_eq!(level2.depth, 2); - } - - #[test] - fn test_dashpay_identity_derivation() { - // Test DashPay contact relationship derivation - let seed = [0x42u8; 64]; - let network = Network::Testnet; - - // Create two test identity IDs - let sender_bytes = [ - 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, - 0xff, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, - 0xdd, 0xee, 0xff, 0x11, - ]; - let recipient_bytes = [ - 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, - 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, - 0x66, 0x77, 0x88, 0x99, - ]; - - let sender_id = Identifier::from_bytes(&sender_bytes).unwrap(); - let recipient_id = Identifier::from_bytes(&recipient_bytes).unwrap(); - - // Test that we can derive the DashPay contact xpub - let xpub = derive_dashpay_incoming_xpub_dip14( - &seed, - network, - 0, // account - &sender_id, - &recipient_id, - ); - - // Print the error if it fails - if let Err(ref e) = xpub { - eprintln!("DashPay derivation error: {}", e); - } - - assert!(xpub.is_ok()); - let xpub = xpub.unwrap(); - - // Verify the derivation depth is correct (base path + 2 identity levels) - // m/9'/5'/15'/0'/(sender)/(recipient) = depth 6 - assert_eq!(xpub.depth, 6); - } -} diff --git a/src/backend_task/dashpay/hd_derivation.rs b/src/backend_task/dashpay/hd_derivation.rs deleted file mode 100644 index 6db0e179f..000000000 --- a/src/backend_task/dashpay/hd_derivation.rs +++ /dev/null @@ -1,399 +0,0 @@ -use dash_sdk::dpp::dashcore::Network; -use dash_sdk::dpp::dashcore::hashes::{Hash, HashEngine}; -use dash_sdk::dpp::key_wallet::bip32::{ - ChildNumber, DerivationPath, ExtendedPrivKey, ExtendedPubKey, -}; -use dash_sdk::platform::Identifier; -use std::str::FromStr; - -// Import our DIP-14 compliant derivation functions -use super::dip14_derivation::derive_dashpay_incoming_xpub_dip14; - -/// DashPay auto-accept proof feature index - use the constant from dip9 if available -const DASHPAY_AUTO_ACCEPT_FEATURE: u32 = 16; - -/// Derive the DashPay incoming funds extended public key for a contact relationship -/// Path: m/9'/5'/15'/account'/(sender_id)/(recipient_id) -/// -/// This creates a unique derivation path for each contact relationship, -/// allowing for unique payment addresses between any two identities. -/// -/// This function now uses DIP-14 compliant 256-bit derivation for identity IDs. -pub fn derive_dashpay_incoming_xpub( - master_seed: &[u8], - network: Network, - account: u32, - sender_id: &Identifier, - recipient_id: &Identifier, -) -> Result { - // Use the DIP-14 compliant implementation - derive_dashpay_incoming_xpub_dip14(master_seed, network, account, sender_id, recipient_id) -} - -/// Derive a specific payment address for a contact -/// Path: ..../index (where index is the address index) -pub fn derive_payment_address( - contact_xpub: &ExtendedPubKey, - index: u32, -) -> Result { - let secp = dash_sdk::dpp::dashcore::secp256k1::Secp256k1::new(); - - // Derive the specific address key - let address_key = contact_xpub - .derive_pub( - &secp, - &[ChildNumber::from_normal_idx(index).map_err(|e| format!("Invalid index: {}", e))?], - ) - .map_err(|e| format!("Failed to derive address key: {}", e))?; - - // Convert to Dash address - // The ExtendedPubKey's public_key is a secp256k1::PublicKey - // We need to convert it to dashcore::PublicKey - let secp_pubkey = address_key.public_key; - let pubkey = dash_sdk::dpp::dashcore::PublicKey::new(secp_pubkey); - let address = dash_sdk::dpp::dashcore::Address::p2pkh(&pubkey, contact_xpub.network); - - Ok(address) -} - -/// Convert an Identifier to a ChildNumber for compatibility with existing code -/// Note: This is only used for backwards compatibility. The actual DIP-14 -/// compliant derivation is handled in the dip14_derivation module. -#[allow(dead_code)] -fn identity_to_child_number(id: &Identifier, hardened: bool) -> Result { - let id_bytes = id.to_buffer(); - - // Take last 4 bytes for ChildNumber representation - // This is just for storage/display purposes, actual derivation uses full 256-bit - let mut index_bytes = [0u8; 4]; - index_bytes.copy_from_slice(&id_bytes[28..32]); - let index = u32::from_be_bytes(index_bytes); - - if hardened { - ChildNumber::from_hardened_idx(index).map_err(|e| format!("Invalid hardened index: {}", e)) - } else { - ChildNumber::from_normal_idx(index).map_err(|e| format!("Invalid normal index: {}", e)) - } -} - -/// Generate the extended public key data for a contact request -/// Returns (parent_fingerprint, chain_code, public_key_bytes) -#[allow(clippy::type_complexity)] -pub fn generate_contact_xpub_data( - master_seed: &[u8], - network: Network, - account: u32, - sender_id: &Identifier, - recipient_id: &Identifier, -) -> Result<([u8; 4], [u8; 32], [u8; 33]), String> { - // Derive the extended public key for this contact - let xpub = - derive_dashpay_incoming_xpub(master_seed, network, account, sender_id, recipient_id)?; - - // Extract the components needed for the contact request - let parent_fingerprint = xpub.parent_fingerprint.to_bytes(); - let chain_code = xpub.chain_code.to_bytes(); - - // Get the public key bytes (33 bytes compressed) - let public_key_bytes = xpub.public_key.serialize(); - - Ok((parent_fingerprint, chain_code, public_key_bytes)) -} - -/// Derive auto-accept proof key according to DIP-0015 -/// Path: m/9'/5'/16'/timestamp' -pub fn derive_auto_accept_key( - master_seed: &[u8], - network: Network, - timestamp: u32, -) -> Result { - // Create extended private key from seed - let master_xprv = ExtendedPrivKey::new_master(network, master_seed) - .map_err(|e| format!("Failed to create master key: {}", e))?; - - // Build derivation path: m/9'/5'/16'/timestamp' - let path = DerivationPath::from_str(&format!( - "m/9'/5'/{}'/{}'", - DASHPAY_AUTO_ACCEPT_FEATURE, timestamp - )) - .map_err(|e| format!("Invalid derivation path: {}", e))?; - - // Derive the key - let auto_accept_key = master_xprv - .derive_priv(&dash_sdk::dpp::dashcore::secp256k1::Secp256k1::new(), &path) - .map_err(|e| format!("Failed to derive auto-accept key: {}", e))?; - - Ok(auto_accept_key) -} - -/// Calculate account reference as specified in DIP-0015 -pub fn calculate_account_reference( - sender_secret_key: &[u8], - extended_public_key: &ExtendedPubKey, - account: u32, - version: u32, -) -> u32 { - use dash_sdk::dpp::dashcore::hashes::hmac::{Hmac, HmacEngine}; - use dash_sdk::dpp::dashcore::hashes::sha256; - - // Serialize the extended public key - let xpub_bytes = extended_public_key.encode(); - - // Create HMAC-SHA256(senderSecretKey, extendedPublicKey) - let mut engine = HmacEngine::::new(sender_secret_key); - engine.input(&xpub_bytes); - let ask = Hmac::::from_engine(engine); - - // Take the 28 most significant bits - let ask_bytes = ask.to_byte_array(); - let ask28 = u32::from_be_bytes([ask_bytes[0], ask_bytes[1], ask_bytes[2], ask_bytes[3]]) >> 4; - - // Prepare account reference - let shortened_account_bits = account & 0x0FFFFFFF; - let version_bits = version << 28; - - // Combine: Version | (ASK28 XOR ShortenedAccountBits) - version_bits | (ask28 ^ shortened_account_bits) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_dashpay_derivation_path() { - // Test that we can create valid derivation paths - let path = DerivationPath::from_str("m/9'/5'/15'/0'").unwrap(); - assert_eq!(path.len(), 4); - } - - #[test] - fn test_account_reference_calculation() { - // Test account reference calculation - let secret_key = [1u8; 32]; - let network = Network::Testnet; - let master_seed = [2u8; 64]; - - let master_xprv = ExtendedPrivKey::new_master(network, &master_seed).unwrap(); - let xpub = ExtendedPubKey::from_priv( - &dash_sdk::dpp::dashcore::secp256k1::Secp256k1::new(), - &master_xprv, - ); - - let account_ref = calculate_account_reference(&secret_key, &xpub, 0, 0); - - // Verify version bits are in the right place - assert_eq!(account_ref >> 28, 0); - } - - #[test] - fn test_derive_payment_address_deterministic() { - // Test that deriving the same address index gives the same address - let network = Network::Testnet; - let master_seed = [0x42u8; 64]; - - // Create two test identity IDs - let sender_bytes = [0x11u8; 32]; - let recipient_bytes = [0x22u8; 32]; - let sender_id = Identifier::from_bytes(&sender_bytes).unwrap(); - let recipient_id = Identifier::from_bytes(&recipient_bytes).unwrap(); - - // Derive xpub twice - let xpub1 = - derive_dashpay_incoming_xpub(&master_seed, network, 0, &sender_id, &recipient_id) - .expect("Should derive xpub"); - let xpub2 = - derive_dashpay_incoming_xpub(&master_seed, network, 0, &sender_id, &recipient_id) - .expect("Should derive xpub"); - - // Derive addresses from both xpubs - let addr1 = derive_payment_address(&xpub1, 0).expect("Should derive address"); - let addr2 = derive_payment_address(&xpub2, 0).expect("Should derive address"); - - // Same seed + identities + index should give same address - assert_eq!( - addr1, addr2, - "Deterministic derivation should produce same address" - ); - } - - #[test] - fn test_derive_payment_address_different_indices() { - // Test that different indices produce different addresses - let network = Network::Testnet; - let master_seed = [0x42u8; 64]; - - let sender_bytes = [0x11u8; 32]; - let recipient_bytes = [0x22u8; 32]; - let sender_id = Identifier::from_bytes(&sender_bytes).unwrap(); - let recipient_id = Identifier::from_bytes(&recipient_bytes).unwrap(); - - let xpub = - derive_dashpay_incoming_xpub(&master_seed, network, 0, &sender_id, &recipient_id) - .expect("Should derive xpub"); - - let addr0 = derive_payment_address(&xpub, 0).expect("Should derive address at index 0"); - let addr1 = derive_payment_address(&xpub, 1).expect("Should derive address at index 1"); - let addr2 = derive_payment_address(&xpub, 2).expect("Should derive address at index 2"); - - // Different indices should produce different addresses - assert_ne!( - addr0, addr1, - "Different indices should produce different addresses" - ); - assert_ne!( - addr1, addr2, - "Different indices should produce different addresses" - ); - assert_ne!( - addr0, addr2, - "Different indices should produce different addresses" - ); - } - - #[test] - fn test_auto_accept_key_derivation() { - // Test auto-accept key derivation - let network = Network::Testnet; - let master_seed = [0x42u8; 64]; - let timestamp = 1700000000u32; - - let key = derive_auto_accept_key(&master_seed, network, timestamp) - .expect("Should derive auto-accept key"); - - // Verify the key was derived correctly - assert_eq!(key.network, network); - assert_eq!(key.depth, 4); // m/9'/5'/16'/timestamp' = depth 4 - } - - #[test] - fn test_auto_accept_key_different_timestamps() { - // Different timestamps should produce different keys - let network = Network::Testnet; - let master_seed = [0x42u8; 64]; - - let key1 = - derive_auto_accept_key(&master_seed, network, 1700000000).expect("Should derive key 1"); - let key2 = - derive_auto_accept_key(&master_seed, network, 1700000001).expect("Should derive key 2"); - - // Different timestamps should produce different keys - assert_ne!( - key1.private_key.secret_bytes(), - key2.private_key.secret_bytes(), - "Different timestamps should produce different keys" - ); - } - - #[test] - fn test_generate_contact_xpub_data() { - // Test generating contact xpub data - let network = Network::Testnet; - let master_seed = [0x42u8; 64]; - - let sender_bytes = [0x11u8; 32]; - let recipient_bytes = [0x22u8; 32]; - let sender_id = Identifier::from_bytes(&sender_bytes).unwrap(); - let recipient_id = Identifier::from_bytes(&recipient_bytes).unwrap(); - - let (fingerprint, chain_code, pubkey) = - generate_contact_xpub_data(&master_seed, network, 0, &sender_id, &recipient_id) - .expect("Should generate xpub data"); - - // Verify the data has the expected sizes - assert_eq!(fingerprint.len(), 4, "Fingerprint should be 4 bytes"); - assert_eq!(chain_code.len(), 32, "Chain code should be 32 bytes"); - assert_eq!( - pubkey.len(), - 33, - "Public key should be 33 bytes (compressed)" - ); - - // Verify public key is valid compressed format (starts with 0x02 or 0x03) - assert!( - pubkey[0] == 0x02 || pubkey[0] == 0x03, - "Public key should start with 0x02 or 0x03" - ); - } - - #[test] - fn test_account_reference_version_bits() { - // Test that version bits are correctly placed - let secret_key = [1u8; 32]; - let network = Network::Testnet; - let master_seed = [2u8; 64]; - - let master_xprv = ExtendedPrivKey::new_master(network, &master_seed).unwrap(); - let secp = dash_sdk::dpp::dashcore::secp256k1::Secp256k1::new(); - let xpub = ExtendedPubKey::from_priv(&secp, &master_xprv); - - // Test version 0 - let ref_v0 = calculate_account_reference(&secret_key, &xpub, 0, 0); - assert_eq!(ref_v0 >> 28, 0, "Version 0 should have 0 in top 4 bits"); - - // Test version 1 - let ref_v1 = calculate_account_reference(&secret_key, &xpub, 0, 1); - assert_eq!(ref_v1 >> 28, 1, "Version 1 should have 1 in top 4 bits"); - - // Test version 15 (max) - let ref_v15 = calculate_account_reference(&secret_key, &xpub, 0, 15); - assert_eq!(ref_v15 >> 28, 15, "Version 15 should have 15 in top 4 bits"); - } - - #[test] - fn test_dashpay_xpub_different_accounts() { - // Different accounts should produce different xpubs - let network = Network::Testnet; - let master_seed = [0x42u8; 64]; - - let sender_bytes = [0x11u8; 32]; - let recipient_bytes = [0x22u8; 32]; - let sender_id = Identifier::from_bytes(&sender_bytes).unwrap(); - let recipient_id = Identifier::from_bytes(&recipient_bytes).unwrap(); - - let xpub_account0 = - derive_dashpay_incoming_xpub(&master_seed, network, 0, &sender_id, &recipient_id) - .expect("Should derive xpub for account 0"); - let xpub_account1 = - derive_dashpay_incoming_xpub(&master_seed, network, 1, &sender_id, &recipient_id) - .expect("Should derive xpub for account 1"); - - assert_ne!( - xpub_account0.public_key.serialize(), - xpub_account1.public_key.serialize(), - "Different accounts should produce different xpubs" - ); - } - - #[test] - fn test_dashpay_xpub_different_networks() { - // Different networks should produce different xpubs - let master_seed = [0x42u8; 64]; - - let sender_bytes = [0x11u8; 32]; - let recipient_bytes = [0x22u8; 32]; - let sender_id = Identifier::from_bytes(&sender_bytes).unwrap(); - let recipient_id = Identifier::from_bytes(&recipient_bytes).unwrap(); - - let xpub_testnet = derive_dashpay_incoming_xpub( - &master_seed, - Network::Testnet, - 0, - &sender_id, - &recipient_id, - ) - .expect("Should derive xpub for testnet"); - let xpub_mainnet = derive_dashpay_incoming_xpub( - &master_seed, - Network::Mainnet, - 0, - &sender_id, - &recipient_id, - ) - .expect("Should derive xpub for mainnet"); - - // Keys should be the same but network should differ - assert_eq!(xpub_testnet.network, Network::Testnet); - assert_eq!(xpub_mainnet.network, Network::Mainnet); - } -} diff --git a/src/backend_task/dashpay/incoming_payments.rs b/src/backend_task/dashpay/incoming_payments.rs index 5ddcbe95c..8f32c4654 100644 --- a/src/backend_task/dashpay/incoming_payments.rs +++ b/src/backend_task/dashpay/incoming_payments.rs @@ -1,4 +1,3 @@ -use super::hd_derivation::{derive_dashpay_incoming_xpub, derive_payment_address}; use crate::context::AppContext; use crate::model::qualified_identity::QualifiedIdentity; use dash_sdk::dpp::dashcore::{Address, Network}; @@ -30,9 +29,12 @@ pub struct DashPayAddressRegistrationResult { /// Derive the receiving addresses for a contact relationship /// These are the addresses the CONTACT will use to pay US -/// Path: m/9'/5'/15'/account'/(our_id)/(contact_id)/index +/// Path: m/9'/coin'/15'/account'/(our_id)/(contact_id)/index +/// +/// Uses platform-wallet's DIP-14 derivation via `derive_contact_xpub` and +/// `derive_contact_payment_address`. pub fn derive_receiving_addresses_for_contact( - master_seed: &[u8], + key_wallet: &dash_sdk::dpp::key_wallet::wallet::Wallet, network: Network, our_identity_id: &Identifier, contact_id: &Identifier, @@ -40,28 +42,34 @@ pub fn derive_receiving_addresses_for_contact( count: u32, ) -> Result, String> { // For receiving payments, we derive from OUR xpub - // Path: m/9'/5'/15'/0'/(our_id)/(contact_id) - // This is the key we sent to the contact in our contact request - let xpub = derive_dashpay_incoming_xpub( - master_seed, + // Path: m/9'/coin'/15'/0'/(our_id)/(contact_id) + let xpub_data = platform_wallet::derive_contact_xpub( + key_wallet, network, 0, // account 0 our_identity_id, contact_id, - )?; + ) + .map_err(|e| format!("Failed to derive contact xpub: {}", e))?; - let mut addresses = Vec::with_capacity(count as usize); - for i in start_index..(start_index + count) { - let address = derive_payment_address(&xpub, i)?; - addresses.push(DashPayReceivingAddress { + let addresses = platform_wallet::derive_contact_payment_addresses( + &xpub_data.xpub, + start_index, + count, + network, + ) + .map_err(|e| format!("Failed to derive payment addresses: {}", e))?; + + Ok(addresses + .into_iter() + .enumerate() + .map(|(i, address)| DashPayReceivingAddress { address, contact_id: *contact_id, owner_id: *our_identity_id, - address_index: i, - }); - } - - Ok(addresses) + address_index: start_index + i as u32, + }) + .collect()) } /// Register DashPay receiving addresses for all contacts of an identity @@ -74,22 +82,23 @@ pub async fn register_dashpay_addresses_for_identity( let mut result = DashPayAddressRegistrationResult::default(); let our_identity_id = identity.identity.id(); - // Get the wallet seed + // Get the evo-tool wallet wrapper let wallet = identity .associated_wallets .values() .next() .ok_or("No wallet associated with identity")?; - let seed = { + // Get the platform wallet's key-wallet Wallet for DIP-14 derivation + let platform_wallet_arc = { let wallet_guard = wallet.read().map_err(|e| e.to_string())?; if !wallet_guard.is_open() { return Err("Wallet must be unlocked to register DashPay addresses".to_string()); } wallet_guard - .seed_bytes() - .map_err(|e| format!("Wallet seed not available: {}", e))? - .to_vec() + .platform_wallet + .clone() + .ok_or("Platform wallet not available")? }; // Load all contacts for this identity from the database @@ -117,6 +126,10 @@ pub async fn register_dashpay_addresses_for_identity( let network = app_context.network; + // Acquire the key-wallet read guard for derivation + let info_guard = platform_wallet_arc.state().await; + let key_wallet_guard = info_guard.managed_state.wallet(); + for contact in contacts { let contact_id = match Identifier::from_bytes(&contact.contact_identity_id) { Ok(id) => id, @@ -153,7 +166,7 @@ pub async fn register_dashpay_addresses_for_identity( // Derive the receiving addresses match derive_receiving_addresses_for_contact( - &seed, + key_wallet_guard, network, &our_identity_id, &contact_id, @@ -209,64 +222,24 @@ pub async fn register_dashpay_addresses_for_identity( Ok(result) } -/// Register a single DashPay address with the wallet +/// Register a DashPay address mapping in the database. +/// +/// Stores the address → (owner_id, contact_id, index) mapping so incoming +/// payments can be matched to the correct contact relationship. +/// Address monitoring is handled by ManagedWalletInfo's DashpayReceivingFunds +/// account pools — no additional address registration needed. fn register_dashpay_address( app_context: &AppContext, - wallet: &Arc>, + _wallet: &Arc>, address: &Address, owner_id: &Identifier, contact_id: &Identifier, address_index: u32, ) -> Result<(), String> { - use crate::model::wallet::{DerivationPathReference, DerivationPathType}; - use dash_sdk::dpp::key_wallet::bip32::{ChildNumber, DerivationPath}; - - // Create a derivation path representation for DashPay addresses - // m/9'/5'/15'/0'/// - // Note: We use a simplified representation since full 256-bit paths don't fit in standard BIP32 - let path = DerivationPath::from(vec![ - ChildNumber::from_hardened_idx(9).unwrap(), // Feature purpose - ChildNumber::from_hardened_idx(5).unwrap(), // Coin type (Dash) - ChildNumber::from_hardened_idx(15).unwrap(), // DashPay feature - ChildNumber::from_hardened_idx(0).unwrap(), // Account - // For the identity indices, we use a hash to fit in u32 - ChildNumber::from_normal_idx(hash_identifier_to_u32(owner_id)).unwrap(), - ChildNumber::from_normal_idx(hash_identifier_to_u32(contact_id)).unwrap(), - ChildNumber::from_normal_idx(address_index).unwrap(), - ]); - - // Store the DashPay address mapping in the database app_context .db .save_dashpay_address_mapping(owner_id, contact_id, address, address_index) - .map_err(|e| format!("Failed to save address mapping: {}", e))?; - - // Register with the wallet's known addresses - let mut guard = wallet.write().map_err(|e| e.to_string())?; - - if guard.known_addresses.contains_key(address) { - return Ok(()); // Already registered - } - - guard.known_addresses.insert(address.clone(), path.clone()); - guard.watched_addresses.insert( - path, - crate::model::wallet::AddressInfo { - address: address.clone(), - path_type: DerivationPathType::DASHPAY, - path_reference: DerivationPathReference::ContactBasedFunds, - }, - ); - - Ok(()) -} - -/// Hash an identifier to a u32 for use in derivation path representation -fn hash_identifier_to_u32(id: &Identifier) -> u32 { - use dash_sdk::dpp::dashcore::hashes::{Hash, sha256}; - let hash = sha256::Hash::hash(&id.to_buffer()); - let bytes = hash.to_byte_array(); - u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) & 0x7FFFFFFF + .map_err(|e| format!("Failed to save address mapping: {}", e)) } /// Match a received transaction to a DashPay contact @@ -344,16 +317,3 @@ pub struct IncomingPaymentInfo { pub amount_duffs: u64, pub address_index: u32, } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_hash_identifier_to_u32() { - let id = Identifier::random(); - let hash = hash_identifier_to_u32(&id); - // Should be less than 2^31 (non-hardened range) - assert!(hash < 0x80000000); - } -} diff --git a/src/backend_task/dashpay/payments.rs b/src/backend_task/dashpay/payments.rs index ecc5bd266..fda2ea9eb 100644 --- a/src/backend_task/dashpay/payments.rs +++ b/src/backend_task/dashpay/payments.rs @@ -1,5 +1,4 @@ use super::encryption::decrypt_extended_public_key; -use super::hd_derivation::derive_payment_address; use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::error::TaskError; use crate::context::AppContext; @@ -194,8 +193,8 @@ pub async fn derive_contact_payment_address( let address_index = get_next_address_index(app_context, &our_identity.identity.id(), &contact_id).await?; - // Derive the payment address - let address = derive_payment_address(&xpub, address_index) + // Derive the payment address using platform-wallet's DIP-14 derivation + let address = platform_wallet::derive_contact_payment_address(&xpub, address_index, network) .map_err(|e| format!("Failed to derive payment address: {}", e))?; Ok((address, address_index)) diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index b27416caa..0427667f5 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -550,6 +550,20 @@ pub enum TaskError { #[error("Could not retrieve token information from the platform. Please retry.")] TokenQueryError { detail: String }, + /// A token operation via the platform wallet failed. + #[error("The token operation could not be completed. Please check your input and retry.")] + PlatformWalletTokenError { + #[source] + source: Box, + }, + + /// A platform-wallet operation failed (e.g. DashPay contact request). + #[error("The operation could not be completed. Please check your wallet and retry.")] + PlatformWallet { + #[source] + source: Box, + }, + // ────────────────────────────────────────────────────────────────────────── // Contract schema errors // ────────────────────────────────────────────────────────────────────────── @@ -754,6 +768,16 @@ pub enum TaskError { #[error("No identities found up to wallet index {max_index}. Try a higher search range.")] NoWalletIdentitiesFound { max_index: u32 }, + /// The platform-wallet library failed to load an identity at the given wallet index. + #[error( + "Could not load identity at wallet index {identity_index}. Please check your network connection and retry." + )] + IdentityLoadByIndex { + identity_index: u32, + #[source] + source: Box, + }, + // ────────────────────────────────────────────────────────────────────────── // Key input validation errors // ────────────────────────────────────────────────────────────────────────── diff --git a/src/backend_task/identity/add_key_to_identity.rs b/src/backend_task/identity/add_key_to_identity.rs index febaa8610..1e10c57a4 100644 --- a/src/backend_task/identity/add_key_to_identity.rs +++ b/src/backend_task/identity/add_key_to_identity.rs @@ -6,35 +6,28 @@ use crate::model::fee_estimation::PlatformFeeEstimator; use crate::model::qualified_identity::PrivateKeyTarget::PrivateKeyOnMainIdentity; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; -use dash_sdk::Error as SdkError; use dash_sdk::Sdk; use dash_sdk::dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::{ IdentityPublicKeyGettersV0, IdentityPublicKeySettersV0, }; -use dash_sdk::dpp::prelude::UserFeeIncrease; -use dash_sdk::dpp::state_transition::identity_update_transition::IdentityUpdateTransition; -use dash_sdk::dpp::state_transition::identity_update_transition::methods::IdentityUpdateTransitionMethodsV0; use dash_sdk::dpp::state_transition::proof_result::StateTransitionProofResult; -use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; use dash_sdk::platform::{Fetch, Identity}; impl AppContext { pub(super) async fn add_key_to_identity( &self, - sdk: &Sdk, + _sdk: &Sdk, mut qualified_identity: QualifiedIdentity, mut public_key_to_add: QualifiedIdentityPublicKey, private_key: [u8; 32], ) -> Result { - let new_identity_nonce = sdk - .get_identity_nonce(qualified_identity.identity.id(), true, None) - .await?; + let sdk = self.sdk.load().as_ref().clone(); let Some(master_key) = qualified_identity.can_sign_with_master_key() else { return Err(TaskError::MasterKeyNotFound); }; let master_key_id = master_key.identity_public_key.id(); - let identity = Identity::fetch_by_identifier(sdk, qualified_identity.identity.id()) + let identity = Identity::fetch_by_identifier(&sdk, qualified_identity.identity.id()) .await? .ok_or(TaskError::IdentityNotFoundLocally)?; qualified_identity.identity = identity; @@ -53,22 +46,19 @@ impl AppContext { let balance_before = qualified_identity.identity.balance(); let estimated_fee = PlatformFeeEstimator::new().estimate_identity_update(); - let state_transition = IdentityUpdateTransition::try_from_identity_with_signer( - &qualified_identity.identity, - &master_key_id, - vec![public_key_to_add.identity_public_key.clone()], - vec![], - new_identity_nonce, - UserFeeIncrease::default(), - &qualified_identity, - sdk.version(), - None, - ) - .map_err(|e| TaskError::IdentityUpdateTransitionError { - source_error: Box::new(SdkError::Protocol(e)), - })?; + let platform_wallet = self.platform_wallet_for_identity(&qualified_identity)?; + let identity_wallet = platform_wallet.identity(); - let result = state_transition.broadcast_and_wait(sdk, None).await?; + let result = identity_wallet + .update_identity_with_signer( + &qualified_identity.identity, + &master_key_id, + vec![public_key_to_add.identity_public_key.clone()], + vec![], + &qualified_identity, + None, + ) + .await?; // Log and handle the proof result tracing::info!("AddKeyToIdentity proof result: {}", result); diff --git a/src/backend_task/identity/discover_identities.rs b/src/backend_task/identity/discover_identities.rs index c16b61e09..b1351530e 100644 --- a/src/backend_task/identity/discover_identities.rs +++ b/src/backend_task/identity/discover_identities.rs @@ -8,10 +8,219 @@ impl AppContext { /// Discover and load identities derived from a wallet by checking the network. /// This is called automatically on wallet unlock to find any identities that /// were registered using keys from the wallet. + /// + /// When a platform-wallet is available for this wallet, delegates the + /// gap-limit scan + DPNS lookup to `IdentityWallet::sync()` and converts + /// the results into `QualifiedIdentity`. Falls back to the legacy scan + /// when the platform-wallet is not registered. pub(crate) async fn discover_identities_from_wallet( self: &Arc, wallet: &Arc>, max_identity_index: u32, + ) -> Result<(), String> { + let seed_hash = wallet.read().map_err(|e| e.to_string())?.seed_hash(); + + // Try to delegate to platform-wallet's sync() when available. + if let Some(platform_wallet) = self.get_platform_wallet(&seed_hash) { + return self + .discover_identities_via_platform_wallet(wallet, &platform_wallet, &seed_hash) + .await; + } + + // Fallback: legacy scan when platform-wallet is not available. + self.discover_identities_legacy(wallet, max_identity_index, &seed_hash) + .await + } + + /// Delegate identity discovery to the platform-wallet's `IdentityWallet::sync()`. + /// + /// This calls the platform-wallet's gap-limit scanner (12 key indices per + /// identity index, DPNS lookup, key storage) and then converts the + /// discovered `ManagedIdentity` entries into evo-tool's `QualifiedIdentity`. + async fn discover_identities_via_platform_wallet( + self: &Arc, + wallet: &Arc>, + platform_wallet: &crate::platform_wallet_bridge::PlatformWallet, + seed_hash: &[u8; 32], + ) -> Result<(), String> { + use crate::model::qualified_identity::encrypted_key_storage::{ + PrivateKeyData, WalletDerivationPath, + }; + use crate::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; + use crate::model::qualified_identity::{ + IdentityStatus, IdentityType, PrivateKeyTarget, QualifiedIdentity, + }; + + tracing::info!( + seed = %hex::encode(seed_hash), + "Starting identity discovery via platform-wallet sync" + ); + + let identity_wallet = platform_wallet.identity(); + + // Run the platform-wallet gap-limit scan + DPNS lookup. + let discovered = identity_wallet + .sync() + .await + .map_err(|e| format!("Platform wallet identity sync failed: {}", e))?; + + if discovered.is_empty() { + tracing::info!( + seed = %hex::encode(seed_hash), + "Platform-wallet sync found no new identities" + ); + return Ok(()); + } + + tracing::info!( + seed = %hex::encode(seed_hash), + count = discovered.len(), + "Platform-wallet sync discovered identities, converting to QualifiedIdentity" + ); + + // Read back the managed identity data from the identity manager. + let manager = identity_wallet.state().await; + + let mut found_count = 0; + for identity in &discovered { + let identity_id = identity.id(); + + // Check if we already have this identity stored in the evo-tool DB. + let already_exists = { + let wallets = self.wallets.read().map_err(|e| e.to_string())?; + let existing = self.db.get_identity_by_id(&identity_id, self, &wallets); + existing.is_ok() && existing.unwrap().is_some() + }; + + if already_exists { + tracing::info!( + identity_id = %identity_id, + "Identity already loaded, skipping" + ); + continue; + } + + let managed = match manager.identity_manager.managed_identity(&identity_id) { + Some(m) => m, + None => { + tracing::warn!( + identity_id = %identity_id, + "Identity discovered but not found in identity manager" + ); + continue; + } + }; + + let identity_index = managed.identity_index; + + // Convert key storage from platform-wallet types to evo-tool types. + let private_keys_map: std::collections::BTreeMap<_, _> = managed + .key_storage + .iter() + .map(|(key_id, (pub_key, pk_data))| { + let (evo_pk_data, wallet_path) = match pk_data { + platform_wallet::PrivateKeyData::AtWalletDerivationPath { + wallet_seed_hash, + derivation_path, + } => { + let wallet_derivation_path = WalletDerivationPath { + wallet_seed_hash: *wallet_seed_hash, + derivation_path: derivation_path.clone(), + }; + ( + PrivateKeyData::AtWalletDerivationPath( + wallet_derivation_path.clone(), + ), + Some(wallet_derivation_path), + ) + } + platform_wallet::PrivateKeyData::Clear(key_bytes) => { + { + let bytes: &[u8; 32] = key_bytes; + (PrivateKeyData::Clear(*bytes), None) + } + } + }; + + let qualified_pub_key = + QualifiedIdentityPublicKey::from_identity_public_key_in_wallet( + pub_key.clone(), + wallet_path, + ); + + ( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, *key_id), + (qualified_pub_key, evo_pk_data), + ) + }) + .collect(); + + // Convert DPNS names. + let dpns_names: Vec = managed + .dpns_names + .iter() + .map(|n| DPNSNameInfo { + name: n.label.clone(), + acquired_at: n.acquired_at.unwrap_or(0), + }) + .collect(); + + // Build QualifiedIdentity. + let mut associated_wallets = std::collections::BTreeMap::new(); + associated_wallets.insert(*seed_hash, Arc::clone(wallet)); + + let qualified_identity = QualifiedIdentity { + identity: identity.clone(), + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::User, + alias: None, + private_keys: private_keys_map.into(), + dpns_names, + associated_wallets, + wallet_index: Some(identity_index), + top_ups: Default::default(), + status: IdentityStatus::Unknown, + network: self.network, + }; + + // Store the identity in the evo-tool DB. + if let Err(e) = self.insert_local_qualified_identity( + &qualified_identity, + &Some((*seed_hash, identity_index)), + ) { + tracing::warn!( + identity_id = %identity_id, + error = %e, + "Failed to store discovered identity" + ); + } else { + found_count += 1; + tracing::info!( + identity_id = %identity_id, + "Successfully loaded discovered identity via platform-wallet" + ); + } + } + + tracing::info!( + seed = %hex::encode(seed_hash), + found_count, + "Identity discovery via platform-wallet complete" + ); + + Ok(()) + } + + /// Legacy identity discovery that queries Platform directly without the + /// platform-wallet library. Used as a fallback when the platform-wallet + /// is not available for a given wallet. + async fn discover_identities_legacy( + self: &Arc, + wallet: &Arc>, + max_identity_index: u32, + seed_hash: &[u8; 32], ) -> Result<(), String> { use dash_sdk::platform::Fetch; use dash_sdk::platform::types::identity::NonUniquePublicKeyHashQuery; @@ -19,11 +228,10 @@ impl AppContext { const AUTH_KEY_LOOKUP_WINDOW: u32 = 12; let sdk = self.sdk.load().as_ref().clone(); - let seed_hash = wallet.read().map_err(|e| e.to_string())?.seed_hash(); tracing::info!( seed = %hex::encode(seed_hash), - "Starting identity discovery for wallet (checking indices 0..{})", + "Starting legacy identity discovery for wallet (checking indices 0..{})", max_identity_index ); @@ -114,7 +322,7 @@ impl AppContext { // Store the identity if let Err(e) = self.insert_local_qualified_identity( &qualified_identity, - &Some((seed_hash, identity_index)), + &Some((*seed_hash, identity_index)), ) { tracing::warn!( identity_id = %identity_id, @@ -122,12 +330,6 @@ impl AppContext { "Failed to store discovered identity" ); } else { - // Add to wallet's identities map - if let Ok(mut wallet_guard) = wallet.write() { - wallet_guard - .identities - .insert(identity_index, qualified_identity.identity.clone()); - } found_count += 1; tracing::info!( identity_id = %identity_id, @@ -149,7 +351,7 @@ impl AppContext { tracing::info!( seed = %hex::encode(seed_hash), found_count, - "Identity discovery complete" + "Legacy identity discovery complete" ); Ok(()) @@ -245,53 +447,20 @@ impl AppContext { .collect(); // Fetch DPNS names for this identity - let dpns_names = { - use dash_sdk::dpp::document::DocumentV0Getters; - use dash_sdk::dpp::platform_value::Value; - use dash_sdk::drive::query::{WhereClause, WhereOperator}; - use dash_sdk::platform::{Document, DocumentQuery, FetchMany}; - - let query = DocumentQuery { - data_contract: self.dpns_contract.clone(), - document_type_name: "domain".to_string(), - where_clauses: vec![WhereClause { - field: "records.identity".to_string(), - operator: WhereOperator::Equal, - value: Value::Identifier(identity.id().into()), - }], - order_by_clauses: vec![], - limit: 100, - start: None, - }; - - match Document::fetch_many(sdk, query).await { - Ok(document_map) => document_map - .values() - .filter_map(|maybe_doc| { - maybe_doc.as_ref().and_then(|doc| { - let name = doc - .get("label") - .map(|label| label.to_str().unwrap_or_default()); - let acquired_at = doc - .created_at() - .into_iter() - .chain(doc.transferred_at()) - .max(); - - match (name, acquired_at) { - (Some(name), Some(acquired_at)) => Some(DPNSNameInfo { - name: name.to_string(), - acquired_at, - }), - _ => None, - } - }) - }) - .collect::>(), - Err(e) => { - tracing::warn!("Failed to fetch DPNS names for identity: {}", e); - Vec::new() - } + let dpns_names = match sdk + .get_dpns_usernames_by_identity(identity.id(), None) + .await + { + Ok(dpns_usernames) => dpns_usernames + .into_iter() + .map(|u| DPNSNameInfo { + name: u.label, + acquired_at: 0, + }) + .collect::>(), + Err(e) => { + tracing::warn!("Failed to fetch DPNS names for identity: {}", e); + Vec::new() } }; diff --git a/src/backend_task/identity/load_identity.rs b/src/backend_task/identity/load_identity.rs index c576e04cd..f2cac24d3 100644 --- a/src/backend_task/identity/load_identity.rs +++ b/src/backend_task/identity/load_identity.rs @@ -18,17 +18,14 @@ use dash_sdk::Sdk; use dash_sdk::dashcore_rpc::dashcore::PrivateKey; use dash_sdk::dashcore_rpc::dashcore::key::Secp256k1; use dash_sdk::dpp::dashcore::hashes::Hash; -use dash_sdk::dpp::document::DocumentV0Getters; use dash_sdk::dpp::identifier::MasternodeIdentifiers; use dash_sdk::dpp::identity::KeyType; use dash_sdk::dpp::identity::SecurityLevel; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dash_sdk::dpp::key_wallet::bip32::{DerivationPath, KeyDerivationType}; -use dash_sdk::dpp::platform_value::Value; use dash_sdk::dpp::platform_value::string_encoding::Encoding; -use dash_sdk::drive::query::{WhereClause, WhereOperator}; -use dash_sdk::platform::{Document, DocumentQuery, Fetch, FetchMany, Identifier, Identity}; +use dash_sdk::platform::{Fetch, Identifier, Identity}; use egui::ahash::HashMap; use std::collections::BTreeMap; use std::convert::TryInto; @@ -315,43 +312,15 @@ impl AppContext { } // Fetch DPNS names using SDK - let dpns_names_document_query = DocumentQuery { - data_contract: self.dpns_contract.clone(), - document_type_name: "domain".to_string(), - where_clauses: vec![WhereClause { - field: "records.identity".to_string(), - operator: WhereOperator::Equal, - value: Value::Identifier(identity_id.into()), - }], - order_by_clauses: vec![], - limit: 100, - start: None, - }; - - let maybe_owned_dpns_names = Document::fetch_many(sdk, dpns_names_document_query) + let maybe_owned_dpns_names = sdk + .get_dpns_usernames_by_identity(identity_id, None) .await - .map(|document_map| { - document_map - .values() - .filter_map(|maybe_doc| { - maybe_doc.as_ref().and_then(|doc| { - let name = doc - .get("label") - .map(|label| label.to_str().unwrap_or_default()); - let acquired_at = doc - .created_at() - .into_iter() - .chain(doc.transferred_at()) - .max(); - - match (name, acquired_at) { - (Some(name), Some(acquired_at)) => Some(DPNSNameInfo { - name: name.to_string(), - acquired_at, - }), - _ => None, - } - }) + .map(|dpns_usernames| { + dpns_usernames + .into_iter() + .map(|u| DPNSNameInfo { + name: u.label, + acquired_at: 0, }) .collect::>() }) @@ -396,15 +365,6 @@ impl AppContext { // Insert qualified identity into the database self.insert_local_qualified_identity(&qualified_identity, &wallet_info)?; - if let Some((wallet_seed_hash, identity_index)) = wallet_info - && let Some(wallet_arc) = wallets.get(&wallet_seed_hash) - { - let mut wallet = wallet_arc.write().map_err(TaskError::from)?; - wallet - .identities - .insert(identity_index, qualified_identity.identity.clone()); - } - Ok(BackendTaskSuccessResult::LoadedIdentity(qualified_identity)) } @@ -456,35 +416,6 @@ impl AppContext { wallet_seed_hash: WalletSeedHash, top_bound: u32, ) -> Result, TaskError> { - let identity_id = identity.id(); - - if let Some((&identity_index, _)) = wallet - .identities - .iter() - .find(|(_, existing)| existing.id() == identity_id) - { - let (public_key_map, public_key_hash_map) = wallet - .identity_authentication_ecdsa_public_keys_data_map( - self, - true, - self.network, - identity_index, - 0..top_bound, - ) - .map_err(|e| TaskError::WalletAddressDerivationFailed { detail: e })?; - let wallet_private_keys = self.build_wallet_private_key_map( - identity, - wallet_seed_hash, - identity_index, - &public_key_map, - &public_key_hash_map, - ); - - if !wallet_private_keys.is_empty() { - return Ok(Some((identity_index, wallet_private_keys))); - } - } - for candidate_index in 0..MAX_IDENTITY_INDEX { let (public_key_map, public_key_hash_map) = wallet .identity_authentication_ecdsa_public_keys_data_map( diff --git a/src/backend_task/identity/load_identity_by_dpns_name.rs b/src/backend_task/identity/load_identity_by_dpns_name.rs index 5e13dfe7c..7c01fe1d6 100644 --- a/src/backend_task/identity/load_identity_by_dpns_name.rs +++ b/src/backend_task/identity/load_identity_by_dpns_name.rs @@ -6,132 +6,73 @@ use crate::model::qualified_identity::{ }; use crate::model::wallet::WalletSeedHash; use dash_sdk::Sdk; -use dash_sdk::dpp::document::DocumentV0Getters; -use dash_sdk::dpp::platform_value::Value; -use dash_sdk::dpp::util::strings::convert_to_homograph_safe_chars; -use dash_sdk::drive::query::{WhereClause, WhereOperator}; -use dash_sdk::platform::{Document, DocumentQuery, Fetch, FetchMany, Identifier, Identity}; +use dash_sdk::platform::{Fetch, Identity}; impl AppContext { - /// Load an identity by its DPNS name + /// Load an identity by its DPNS name. + /// + /// Uses the SDK's `resolve_dpns_name()` for name resolution (replacing the + /// manual DPNS document query), then fetches the identity and all its DPNS + /// names, and builds a `QualifiedIdentity` with optional wallet key + /// matching. pub(super) async fn load_identity_by_dpns_name( &self, sdk: &Sdk, dpns_name: String, selected_wallet_seed_hash: Option, ) -> Result { - // Normalize the name (convert to lowercase and handle homoglyphs) - let normalized_name = convert_to_homograph_safe_chars(&dpns_name); - - // Query the DPNS contract for the domain document - let domain_query = DocumentQuery { - data_contract: self.dpns_contract.clone(), - document_type_name: "domain".to_string(), - where_clauses: vec![ - WhereClause { - field: "normalizedParentDomainName".to_string(), - operator: WhereOperator::Equal, - value: Value::Text("dash".to_string()), - }, - WhereClause { - field: "normalizedLabel".to_string(), - operator: WhereOperator::Equal, - value: Value::Text(normalized_name.clone()), - }, - ], - order_by_clauses: vec![], - limit: 1, - start: None, - }; - - let documents = Document::fetch_many(sdk, domain_query) + // Step 1: Resolve the DPNS name to an identity ID using the SDK. + // + // The SDK's resolve_dpns_name() handles homograph-safe normalization, + // parent domain matching, and normalizedLabel lookup internally. + let identity_id = sdk + .resolve_dpns_name(&dpns_name) .await - .map_err(TaskError::from)?; - - // Get the first (and should be only) document - let domain_doc = documents - .values() - .filter_map(|maybe_doc| maybe_doc.as_ref()) - .next() + .map_err(|e| TaskError::DpnsFetchError { + source: Box::new(e), + })? .ok_or(TaskError::IdentityNotFound)?; - // Extract the identity ID from the records.identity field - let identity_id = domain_doc - .get("records") - .and_then(|records| { - if let Value::Map(map) = records { - map.iter() - .find(|(k, _)| { - if let Value::Text(key) = k { - key == "identity" - } else { - false - } - }) - .map(|(_, v)| v.clone()) - } else { - None - } - }) - .and_then(|id_value| { - if let Value::Identifier(id_bytes) = id_value { - Some(Identifier::from(id_bytes)) - } else { - None - } - }) - .ok_or(TaskError::IdentityNotFound)?; + // Step 2: Also notify the platform-wallet (if available) so it can add + // the identity to its watched_identities collection. + // + // We pick any available platform wallet since they all share the same + // SDK and the watched_identities store is per-wallet. + if let Some(platform_wallet) = self.first_available_platform_wallet() + && let Err(e) = platform_wallet + .identity() + .load_identity_by_dpns_name(&dpns_name) + .await + { + tracing::debug!( + "Platform-wallet load_identity_by_dpns_name failed (non-fatal): {}", + e + ); + } - // Fetch the identity + // Step 3: Fetch the identity from Platform. let identity = match Identity::fetch_by_identifier(sdk, identity_id).await { Ok(Some(identity)) => identity, Ok(None) => return Err(TaskError::IdentityNotFound), Err(e) => return Err(TaskError::from(e)), }; - // Get the label from the document for display - let label = domain_doc - .get("label") - .and_then(|l| l.to_str().ok()) + // Extract a display label from the input name. + let label = dpns_name + .strip_suffix(".dash") .unwrap_or(&dpns_name) .to_string(); - // Fetch all DPNS names owned by this identity - let dpns_names_document_query = DocumentQuery { - data_contract: self.dpns_contract.clone(), - document_type_name: "domain".to_string(), - where_clauses: vec![WhereClause { - field: "records.identity".to_string(), - operator: WhereOperator::Equal, - value: Value::Identifier(identity_id.into()), - }], - order_by_clauses: vec![], - limit: 100, - start: None, - }; - - let owned_dpns_names = Document::fetch_many(sdk, dpns_names_document_query) + // Step 4: Fetch all DPNS names owned by this identity. + let owned_dpns_names = sdk + .get_dpns_usernames_by_identity(identity_id, None) .await - .map(|document_map| { - document_map - .values() - .filter_map(|maybe_doc| { - maybe_doc.as_ref().and_then(|doc| { - let name = doc.get("label").map(|l| l.to_str().unwrap_or_default()); - let acquired_at = doc - .created_at() - .into_iter() - .chain(doc.transferred_at()) - .max(); - - match (name, acquired_at) { - (Some(name), Some(acquired_at)) => Some(DPNSNameInfo { - name: name.to_string(), - acquired_at, - }), - _ => None, - } - }) + .map(|dpns_usernames| { + dpns_usernames + .into_iter() + .map(|u| DPNSNameInfo { + name: u.label, + acquired_at: 0, }) .collect::>() }) @@ -139,7 +80,7 @@ impl AppContext { let wallets = self.wallets.read().map_err(TaskError::from)?.clone(); - // Try to derive keys from wallets if requested + // Step 5: Try to derive keys from wallets if requested. let mut encrypted_private_keys = std::collections::BTreeMap::new(); if let Some((_, _, wallet_private_keys)) = self.match_user_identity_keys_with_wallet( @@ -175,7 +116,7 @@ impl AppContext { .determine_wallet_info() .map_err(|e| TaskError::WalletInfoDeterminationFailed { detail: e })?; - // Insert qualified identity into the database + // Insert qualified identity into the database. self.insert_local_qualified_identity(&qualified_identity, &wallet_info)?; Ok(BackendTaskSuccessResult::LoadedIdentity(qualified_identity)) diff --git a/src/backend_task/identity/load_identity_from_wallet.rs b/src/backend_task/identity/load_identity_from_wallet.rs index 0fc330b70..7aede99f2 100644 --- a/src/backend_task/identity/load_identity_from_wallet.rs +++ b/src/backend_task/identity/load_identity_from_wallet.rs @@ -11,26 +11,184 @@ use crate::model::qualified_identity::{ }; use crate::model::wallet::WalletArcRef; use dash_sdk::Sdk; -use dash_sdk::dpp::document::DocumentV0Getters; -use dash_sdk::dpp::identity::KeyType; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; -use dash_sdk::dpp::identity::hash::IdentityPublicKeyHashMethodsV0; -use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; -use dash_sdk::dpp::key_wallet::bip32::{DerivationPath, KeyDerivationType}; -use dash_sdk::dpp::platform_value::Value; -use dash_sdk::drive::query::{WhereClause, WhereOperator}; -use dash_sdk::platform::types::identity::NonUniquePublicKeyHashQuery; -use dash_sdk::platform::{Document, DocumentQuery, Fetch, FetchMany, Identity}; use std::collections::BTreeMap; impl AppContext { pub(super) async fn load_user_identity_from_wallet( &self, - sdk: &Sdk, + _sdk: &Sdk, wallet_arc_ref: WalletArcRef, identity_index: IdentityIndex, _sender: crate::utils::egui_mpsc::SenderAsync, ) -> Result { + let seed_hash = wallet_arc_ref.seed_hash; + + // Try to delegate to platform-wallet when available. + if let Some(platform_wallet) = self.get_platform_wallet(&seed_hash) { + return self + .load_identity_via_platform_wallet( + &wallet_arc_ref, + &platform_wallet, + identity_index, + ) + .await; + } + + // Fallback: legacy direct SDK lookup. + self.load_identity_legacy(_sdk, wallet_arc_ref, identity_index) + .await + } + + /// Delegate identity loading to platform-wallet's `load_identity_by_index()`. + /// + /// The platform-wallet method handles: + /// - Deriving the authentication key hash + /// - Querying Platform for the identity + /// - Building key_storage with derivation paths + /// - DPNS name lookup + /// + /// This method then reads back the `ManagedIdentity` and converts it to + /// evo-tool's `QualifiedIdentity`. + async fn load_identity_via_platform_wallet( + &self, + wallet_arc_ref: &WalletArcRef, + platform_wallet: &crate::platform_wallet_bridge::PlatformWallet, + identity_index: IdentityIndex, + ) -> Result { + let identity_wallet = platform_wallet.identity(); + let seed_hash = wallet_arc_ref.seed_hash; + + // Delegate the Platform query + key matching to platform-wallet. + let identity = identity_wallet + .load_identity_by_index(identity_index) + .await + .map_err(|e| TaskError::IdentityLoadByIndex { + identity_index, + source: Box::new(dash_sdk::Error::Generic(e.to_string())), + })?; + + let identity = match identity { + Some(id) => id, + None => { + return Err(TaskError::WalletIdentityNotFound { + identity_index, + auth_key_count: 12, // platform-wallet checks key_index 0 + }); + } + }; + + let identity_id = identity.id(); + + // Read the enriched ManagedIdentity from the identity manager. + let manager = identity_wallet.state().await; + let managed = manager.identity_manager.managed_identity(&identity_id).ok_or_else(|| { + TaskError::WalletIdentityNotFound { + identity_index, + auth_key_count: 12, + } + })?; + + // Convert key_storage from platform-wallet types to evo-tool types. + let private_keys_map: BTreeMap<_, _> = managed + .key_storage + .iter() + .map(|(key_id, (pub_key, pk_data))| { + let (evo_pk_data, wallet_path) = match pk_data { + platform_wallet::PrivateKeyData::AtWalletDerivationPath { + wallet_seed_hash, + derivation_path, + } => { + let wallet_derivation_path = WalletDerivationPath { + wallet_seed_hash: *wallet_seed_hash, + derivation_path: derivation_path.clone(), + }; + ( + PrivateKeyData::AtWalletDerivationPath(wallet_derivation_path.clone()), + Some(wallet_derivation_path), + ) + } + platform_wallet::PrivateKeyData::Clear(key_bytes) => { + let bytes: &[u8; 32] = key_bytes; + (PrivateKeyData::Clear(*bytes), None) + } + }; + + let qualified_pub_key = + QualifiedIdentityPublicKey::from_identity_public_key_in_wallet( + pub_key.clone(), + wallet_path, + ); + + ( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, *key_id), + (qualified_pub_key, evo_pk_data), + ) + }) + .collect(); + + if private_keys_map.is_empty() { + return Err(TaskError::NoMatchingWalletKeys); + } + + // Convert DPNS names. + let dpns_names: Vec = managed + .dpns_names + .iter() + .map(|n| DPNSNameInfo { + name: n.label.clone(), + acquired_at: n.acquired_at.unwrap_or(0), + }) + .collect(); + + // Build QualifiedIdentity. + let mut associated_wallets = BTreeMap::new(); + associated_wallets.insert(seed_hash, wallet_arc_ref.wallet.clone()); + + let qualified_identity = QualifiedIdentity { + identity: identity.clone(), + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::User, + alias: None, + private_keys: private_keys_map.into(), + dpns_names, + associated_wallets, + wallet_index: Some(identity_index), + top_ups: Default::default(), + status: IdentityStatus::Active, + network: self.network, + }; + + self.insert_local_qualified_identity( + &qualified_identity, + &Some((seed_hash, identity_index)), + ) + .map_err(|e| TaskError::Database { source: e })?; + + Ok(BackendTaskSuccessResult::Message( + "Successfully loaded identity".to_string(), + )) + } + + /// Legacy identity loading that queries Platform directly without platform-wallet. + /// + /// Used as a fallback when the platform-wallet is not available for the wallet. + async fn load_identity_legacy( + &self, + sdk: &Sdk, + wallet_arc_ref: WalletArcRef, + identity_index: IdentityIndex, + ) -> Result { + use crate::model::qualified_identity::encrypted_key_storage::WalletDerivationPath; + use dash_sdk::dpp::identity::KeyType; + use dash_sdk::dpp::identity::hash::IdentityPublicKeyHashMethodsV0; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use dash_sdk::dpp::key_wallet::bip32::{DerivationPath, KeyDerivationType}; + use dash_sdk::platform::types::identity::NonUniquePublicKeyHashQuery; + use dash_sdk::platform::{Fetch, Identity}; + const AUTH_KEY_LOOKUP_WINDOW: u32 = 12; let mut fetched_identity: Option = None; @@ -39,7 +197,7 @@ impl AppContext { for key_index in 0..AUTH_KEY_LOOKUP_WINDOW { let public_key = { - let wallet = wallet_arc_ref.wallet.write()?; + let wallet = wallet_arc_ref.wallet.read()?; wallet .identity_authentication_ecdsa_public_key( self.network, @@ -100,43 +258,15 @@ impl AppContext { let identity_id = identity.id(); - let dpns_names_document_query = DocumentQuery { - data_contract: self.dpns_contract.clone(), - document_type_name: "domain".to_string(), - where_clauses: vec![WhereClause { - field: "records.identity".to_string(), - operator: WhereOperator::Equal, - value: Value::Identifier(identity_id.into()), - }], - order_by_clauses: vec![], - limit: 100, - start: None, - }; - - let maybe_owned_dpns_names = Document::fetch_many(sdk, dpns_names_document_query) + let maybe_owned_dpns_names = sdk + .get_dpns_usernames_by_identity(identity_id, None) .await - .map(|document_map| { - document_map - .values() - .filter_map(|maybe_doc| { - maybe_doc.as_ref().and_then(|doc| { - let name = doc - .get("label") - .map(|label| label.to_str().unwrap_or_default()); - let acquired_at = doc - .created_at() - .into_iter() - .chain(doc.transferred_at()) - .max(); - - match (name, acquired_at) { - (Some(name), Some(acquired_at)) => Some(DPNSNameInfo { - name: name.to_string(), - acquired_at, - }), - _ => None, - } - }) + .map(|dpns_usernames| { + dpns_usernames + .into_iter() + .map(|u| DPNSNameInfo { + name: u.label, + acquired_at: 0, }) .collect::>() }) @@ -253,13 +383,6 @@ impl AppContext { ) .map_err(|e| TaskError::Database { source: e })?; - { - let mut wallet = wallet_arc_ref.wallet.write()?; - wallet - .identities - .insert(identity_index, qualified_identity.identity.clone()); - } - Ok(BackendTaskSuccessResult::Message( "Successfully loaded identity".to_string(), )) diff --git a/src/backend_task/identity/mod.rs b/src/backend_task/identity/mod.rs index 3ad5f4277..a11cb3c92 100644 --- a/src/backend_task/identity/mod.rs +++ b/src/backend_task/identity/mod.rs @@ -21,11 +21,11 @@ use crate::model::secret::Secret; use crate::model::wallet::{Wallet, WalletArcRef, WalletSeedHash}; use dash_sdk::Sdk; use dash_sdk::dashcore_rpc::dashcore::key::Secp256k1; -use dash_sdk::dashcore_rpc::dashcore::{Address, PrivateKey, TxOut}; +use dash_sdk::dashcore_rpc::dashcore::{Address, PrivateKey}; use dash_sdk::dpp::ProtocolError; use dash_sdk::dpp::balances::credits::Duffs; +use dash_sdk::dpp::dashcore::OutPoint; use dash_sdk::dpp::dashcore::hashes::Hash; -use dash_sdk::dpp::dashcore::{OutPoint, Transaction}; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::fee::Credits; use dash_sdk::dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; @@ -34,7 +34,6 @@ use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBound use dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use dash_sdk::dpp::identity::{KeyID, KeyType, Purpose, SecurityLevel}; use dash_sdk::dpp::key_wallet::bip32::DerivationPath; -use dash_sdk::dpp::prelude::AssetLockProof; use dash_sdk::platform::{Identifier, Identity, IdentityPublicKey}; use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::{Arc, RwLock}; @@ -289,8 +288,7 @@ pub type IdentityIndex = u32; pub type TopUpIndex = u32; #[derive(Debug, Clone, PartialEq, Eq)] pub enum RegisterIdentityFundingMethod { - UseAssetLock(Address, Box, Box), - FundWithUtxo(OutPoint, TxOut, Address, IdentityIndex), + UseAssetLock(OutPoint), FundWithWallet(Duffs, IdentityIndex), /// Fund identity creation from Platform addresses FundWithPlatformAddresses { @@ -303,8 +301,7 @@ pub enum RegisterIdentityFundingMethod { #[derive(Debug, Clone, PartialEq, Eq)] pub enum TopUpIdentityFundingMethod { - UseAssetLock(Address, Box, Box), - FundWithUtxo(OutPoint, TxOut, Address, IdentityIndex, TopUpIndex), + UseAssetLock(OutPoint), FundWithWallet(Duffs, IdentityIndex, TopUpIndex), } @@ -777,25 +774,8 @@ impl AppContext { inputs ); - // Get the wallet for signing - clone it to avoid holding guard across await - let wallet_clone = { - let wallet = { - let wallets = self.wallets.read()?; - wallets - .get(&wallet_seed_hash) - .cloned() - .ok_or(TaskError::WalletNotFound)? - }; - - let wallet_guard = wallet.read()?; - - // Ensure wallet is open - if !wallet_guard.is_open() { - return Err(TaskError::WalletLocked); - } - - wallet_guard.clone() - }; + // Get the platform wallet for signing (PlatformAddressWallet implements Signer) + let platform_wallet = self.require_platform_wallet(&wallet_seed_hash)?; tracing::info!("Wallet loaded and open, calling top_up_from_addresses..."); @@ -804,7 +784,7 @@ impl AppContext { // Execute the top-up let (address_infos, new_balance) = identity - .top_up_from_addresses(sdk, inputs, &wallet_clone, None) + .top_up_from_addresses(sdk, inputs, platform_wallet.platform(), None) .await?; tracing::info!( @@ -868,17 +848,20 @@ impl AppContext { .await?; // Update destination address balances in any wallets that contain them - // (using proof-verified data from the SDK response) + // (using proof-verified data from the SDK response). { - let wallets = self.wallets.read()?; - for (seed_hash, wallet_arc) in wallets.iter() { + let seed_hashes: Vec = self + .wallets + .read() + .map(|w| w.keys().copied().collect()) + .unwrap_or_default(); + + for seed_hash in seed_hashes { if let Err(e) = - self.update_wallet_platform_address_info_from_sdk(*seed_hash, &address_infos) + self.update_wallet_platform_address_info_from_sdk(seed_hash, &address_infos) { tracing::warn!("Failed to update wallet platform address info: {}", e); } - // Break early since all wallets share the same network addresses - let _ = wallet_arc; // silence unused warning } } diff --git a/src/backend_task/identity/refresh_identity.rs b/src/backend_task/identity/refresh_identity.rs index 5b7d8701c..e258dc4e0 100644 --- a/src/backend_task/identity/refresh_identity.rs +++ b/src/backend_task/identity/refresh_identity.rs @@ -16,9 +16,28 @@ impl AppContext { sender: crate::utils::egui_mpsc::SenderAsync, ) -> Result { let refreshed_identity_id = qualified_identity.identity.id(); - // Fetch the latest state of the identity from Platform - let maybe_refreshed_identity = - Identity::fetch_by_identifier(sdk, refreshed_identity_id).await?; + + // Delegate the Platform fetch to platform-wallet when available, + // falling back to direct SDK call otherwise. + let maybe_platform_wallet = self.platform_wallet_for_identity(&qualified_identity).ok(); + + let maybe_refreshed_identity = if let Some(ref pw) = maybe_platform_wallet { + match pw + .identity() + .refresh_identity_with_signer(&refreshed_identity_id) + .await + { + Ok(identity) => Some(identity), + Err(e) => { + // Platform wallet refresh failed — fall back to direct SDK fetch + // rather than silently marking identity as NotFound. + tracing::warn!("Platform wallet refresh failed, falling back to SDK: {}", e); + Identity::fetch_by_identifier(sdk, refreshed_identity_id).await? + } + } + } else { + Identity::fetch_by_identifier(sdk, refreshed_identity_id).await? + }; // Get local identities let mut local_qualified_identities = self.load_local_qualified_identities()?; diff --git a/src/backend_task/identity/refresh_loaded_identities_dpns_names.rs b/src/backend_task/identity/refresh_loaded_identities_dpns_names.rs index 9d32f0794..9ef52aace 100644 --- a/src/backend_task/identity/refresh_loaded_identities_dpns_names.rs +++ b/src/backend_task/identity/refresh_loaded_identities_dpns_names.rs @@ -3,11 +3,7 @@ use crate::app::TaskResult; use crate::backend_task::error::TaskError; use crate::context::AppContext; use crate::model::qualified_identity::DPNSNameInfo; -use dash_sdk::dpp::document::DocumentV0Getters; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; -use dash_sdk::dpp::platform_value::Value; -use dash_sdk::drive::query::{WhereClause, WhereOperator}; -use dash_sdk::platform::{Document, DocumentQuery, FetchMany}; impl AppContext { pub(super) async fn refresh_loaded_identities_dpns_names( @@ -21,51 +17,20 @@ impl AppContext { for mut qualified_identity in qualified_identities { let identity_id = qualified_identity.identity.id(); - let dpns_names_document_query = DocumentQuery { - data_contract: self.dpns_contract.clone(), - document_type_name: "domain".to_string(), - where_clauses: vec![WhereClause { - field: "records.identity".to_string(), - operator: WhereOperator::Equal, - value: Value::Identifier(identity_id.into()), - }], - order_by_clauses: vec![], - limit: 100, - start: None, - }; - - let owned_dpns_names = Document::fetch_many(&sdk, dpns_names_document_query) + let dpns_usernames = sdk + .get_dpns_usernames_by_identity(identity_id, None) .await - .map(|document_map| { - document_map - .values() - .filter_map(|maybe_doc| { - maybe_doc.as_ref().and_then(|doc| { - let name = doc - .get("label") - .map(|label| label.to_str().unwrap_or_default()); - let acquired_at = doc - .created_at() - .into_iter() - .chain(doc.transferred_at()) - .max(); - - match (name, acquired_at) { - (Some(name), Some(acquired_at)) => Some(DPNSNameInfo { - name: name.to_string(), - acquired_at, - }), - _ => None, - } - }) - }) - .collect::>() - }) .map_err(|e| TaskError::DpnsFetchError { source: Box::new(e), })?; - qualified_identity.dpns_names = owned_dpns_names; + qualified_identity.dpns_names = dpns_usernames + .into_iter() + .map(|u| DPNSNameInfo { + name: u.label, + acquired_at: 0, // Timestamp not available from SDK query + }) + .collect(); if qualified_identity.alias.is_none() && !qualified_identity.dpns_names.is_empty() { let dpns_name = &qualified_identity.dpns_names[0].name; diff --git a/src/backend_task/identity/register_dpns_name.rs b/src/backend_task/identity/register_dpns_name.rs index 1dc7a5e5c..6cbb9fdcc 100644 --- a/src/backend_task/identity/register_dpns_name.rs +++ b/src/backend_task/identity/register_dpns_name.rs @@ -1,131 +1,32 @@ -use std::collections::BTreeMap; - use crate::backend_task::FeeResult; use crate::backend_task::error::TaskError; use crate::model::fee_estimation::PlatformFeeEstimator; use crate::{context::AppContext, model::qualified_identity::DPNSNameInfo}; -use bip39::rand::{Rng, SeedableRng, rngs::StdRng}; use dash_sdk::{ Sdk, dpp::{ - data_contract::{ - accessors::v0::DataContractV0Getters, document_type::accessors::DocumentTypeV0Getters, - }, - document::{DocumentV0, DocumentV0Getters}, - identity::accessors::IdentityGettersV0, - platform_value::{Bytes32, Value}, - util::{hash::hash_double, strings::convert_to_homograph_safe_chars}, + data_contract::accessors::v0::DataContractV0Getters, identity::accessors::IdentityGettersV0, }, - drive::query::{WhereClause, WhereOperator}, platform::Fetch, - platform::{Document, DocumentQuery, FetchMany, transition::put_document::PutDocument}, }; use super::{BackendTaskSuccessResult, RegisterDpnsNameInput}; impl AppContext { pub(super) async fn register_dpns_name( &self, - sdk: &Sdk, + _sdk: &Sdk, input: RegisterDpnsNameInput, ) -> Result { - let mut rng = StdRng::from_entropy(); - let dpns_contract = self.dpns_contract.clone(); - + let sdk = self.sdk.load().as_ref().clone(); let mut qualified_identity = input.qualified_identity; - let entropy = Bytes32::random_with_rng(&mut rng); - let preorder_document_type = dpns_contract - .document_type_for_name("preorder") - .map_err(|_| TaskError::DataContractNotFound)?; - let domain_document_type = dpns_contract - .document_type_for_name("domain") - .map_err(|_| TaskError::DataContractNotFound)?; - - let preorder_id = Document::generate_document_id_v0( - &dpns_contract.id(), - &qualified_identity.identity.id(), - preorder_document_type.name().as_str(), - entropy.as_slice(), - ); - let domain_id = Document::generate_document_id_v0( - &dpns_contract.id(), - &qualified_identity.identity.id(), - domain_document_type.name().as_str(), - entropy.as_slice(), - ); - - let salt: [u8; 32] = rng.r#gen(); - let mut salted_domain_buffer: Vec = vec![]; - salted_domain_buffer.extend(salt); - salted_domain_buffer - .extend((convert_to_homograph_safe_chars(&input.name_input) + ".dash").as_bytes()); - let salted_domain_hash = hash_double(salted_domain_buffer); - - let preorder_document = Document::V0(DocumentV0 { - id: preorder_id, - owner_id: qualified_identity.identity.id(), - creator_id: None, - properties: BTreeMap::from([( - "saltedDomainHash".to_string(), - salted_domain_hash.into(), - )]), - revision: None, - created_at: None, - updated_at: None, - transferred_at: None, - created_at_block_height: None, - updated_at_block_height: None, - transferred_at_block_height: None, - created_at_core_block_height: None, - updated_at_core_block_height: None, - transferred_at_core_block_height: None, - }); - let domain_document = Document::V0(DocumentV0 { - id: domain_id, - owner_id: qualified_identity.identity.id(), - creator_id: None, - properties: BTreeMap::from([ - ("parentDomainName".to_string(), "dash".into()), - ("normalizedParentDomainName".to_string(), "dash".into()), - ("label".to_string(), input.name_input.clone().into()), - ( - "normalizedLabel".to_string(), - convert_to_homograph_safe_chars(&input.name_input).into(), - ), - ("preorderSalt".to_string(), salt.into()), - ( - "records".to_string(), - BTreeMap::from([( - "identity".to_string(), - Into::::into( - qualified_identity.identity.id(), - ), - )]) - .into(), - ), - ( - "subdomainRules".to_string(), - BTreeMap::from([( - "allowSubdomains".to_string(), - Into::::into(false), - )]) - .into(), - ), - ]), - revision: None, - created_at: None, - updated_at: None, - transferred_at: None, - created_at_block_height: None, - updated_at_block_height: None, - transferred_at_block_height: None, - created_at_core_block_height: None, - updated_at_core_block_height: None, - transferred_at_core_block_height: None, - }); - let public_key = qualified_identity - .document_signing_key(&preorder_document_type) + .document_signing_key( + &self + .dpns_contract + .document_type_for_name("preorder") + .map_err(|_| TaskError::DataContractNotFound)?, + ) .ok_or(TaskError::NoDocumentSigningKey)?; let fee_estimator = PlatformFeeEstimator::new(); @@ -133,67 +34,30 @@ impl AppContext { let balance_before = qualified_identity.identity.balance(); - let _ = preorder_document - .put_to_platform_and_wait_for_response( - sdk, - preorder_document_type.to_owned_document_type(), - Some(entropy.0), - public_key.clone(), - None, - &qualified_identity, - None, - ) - .await?; + // Use platform-wallet's register_name_with_signer which handles + // preorder + domain document creation and broadcasting internally. + let platform_wallet = self.platform_wallet_for_identity(&qualified_identity)?; + let identity_wallet = platform_wallet.identity(); - let _ = domain_document - .put_to_platform_and_wait_for_response( - sdk, - domain_document_type.to_owned_document_type(), - Some(entropy.0), + let _full_domain_name = identity_wallet + .register_name_with_signer( + qualified_identity.identity.clone(), + &input.name_input, public_key.clone(), - None, - &qualified_identity, - None, + qualified_identity.clone(), ) .await?; - let dpns_names_document_query = DocumentQuery { - data_contract: self.dpns_contract.clone(), - document_type_name: "domain".to_string(), - where_clauses: vec![WhereClause { - field: "records.identity".to_string(), - operator: WhereOperator::Equal, - value: Value::Identifier(qualified_identity.identity.id().into()), - }], - order_by_clauses: vec![], - limit: 100, - start: None, - }; - - let owned_dpns_names = Document::fetch_many(sdk, dpns_names_document_query) + // Fetch owned DPNS names to update the local qualified identity. + let owned_dpns_names = sdk + .get_dpns_usernames_by_identity(qualified_identity.identity.id(), None) .await - .map(|document_map| { - document_map - .values() - .filter_map(|maybe_doc| { - maybe_doc.as_ref().and_then(|doc| { - let name = doc - .get("label") - .map(|label| label.to_str().unwrap_or_default()); - let acquired_at = doc - .created_at() - .into_iter() - .chain(doc.transferred_at()) - .max(); - - match (name, acquired_at) { - (Some(name), Some(acquired_at)) => Some(DPNSNameInfo { - name: name.to_string(), - acquired_at, - }), - _ => None, - } - }) + .map(|dpns_usernames| { + dpns_usernames + .into_iter() + .map(|u| DPNSNameInfo { + name: u.label, + acquired_at: 0, }) .collect::>() }) @@ -208,7 +72,7 @@ impl AppContext { } let refreshed_identity = dash_sdk::platform::Identity::fetch_by_identifier( - sdk, + &sdk, qualified_identity.identity.id(), ) .await? diff --git a/src/backend_task/identity/register_identity.rs b/src/backend_task/identity/register_identity.rs index ca6a675d9..e783df9fd 100644 --- a/src/backend_task/identity/register_identity.rs +++ b/src/backend_task/identity/register_identity.rs @@ -1,26 +1,20 @@ use crate::backend_task::error::TaskError; use crate::backend_task::identity::{IdentityRegistrationInfo, RegisterIdentityFundingMethod}; use crate::backend_task::{BackendTaskSuccessResult, FeeResult}; -use crate::context::{AppContext, get_transaction_info}; +use crate::context::AppContext; use crate::model::fee_estimation::PlatformFeeEstimator; use crate::model::proof_log_item::RequestType; use crate::model::qualified_identity::{IdentityStatus, IdentityType, QualifiedIdentity}; +use crate::platform_wallet_bridge::IdentityFunding; +use dash_sdk::Error; use dash_sdk::dash_spv::Network; use dash_sdk::dpp::ProtocolError; use dash_sdk::dpp::address_funds::PlatformAddress; -use dash_sdk::dpp::block::extended_epoch_info::ExtendedEpochInfo; use dash_sdk::dpp::dashcore::hashes::Hash; -use dash_sdk::dpp::dashcore::{OutPoint, PrivateKey}; use dash_sdk::dpp::fee::Credits; -use dash_sdk::dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; -use dash_sdk::dpp::native_bls::NativeBlsModule; -use dash_sdk::dpp::prelude::{AddressNonce, AssetLockProof}; -use dash_sdk::dpp::state_transition::identity_create_transition::IdentityCreateTransition; -use dash_sdk::dpp::state_transition::identity_create_transition::methods::IdentityCreateTransitionMethodsV0; -use dash_sdk::platform::transition::put_identity::PutIdentity; +use dash_sdk::dpp::prelude::AddressNonce; use dash_sdk::platform::{Fetch, FetchMany, Identity}; use dash_sdk::query_types::AddressInfo; -use dash_sdk::{Error, Sdk}; use std::collections::BTreeMap; impl AppContext { @@ -38,110 +32,56 @@ impl AppContext { let sdk = self.sdk.load().as_ref().clone(); - let (_, metadata) = ExtendedEpochInfo::fetch_with_metadata(&sdk, 0, None).await?; - let wallet_id; - let (asset_lock_proof, asset_lock_proof_private_key, tx_id) = match identity_funding_method - { - RegisterIdentityFundingMethod::UseAssetLock(address, asset_lock_proof, transaction) => { - let tx_id = transaction.txid(); - - // Scope the read guard so it's dropped before the async DAPI call below - let private_key = { - let wallet = wallet.read().map_err(TaskError::from)?; - wallet_id = wallet.seed_hash(); - wallet - .private_key_for_address(&address, self.network) - .map_err(|e| TaskError::WalletKeyLookupFailed { detail: e })? - .ok_or(TaskError::AssetLockNotValidForWallet)? + let (asset_lock_proof, out_point) = match identity_funding_method { + RegisterIdentityFundingMethod::UseAssetLock(out_point) => { + let platform_wallet = { + let guard = wallet.read().map_err(TaskError::from)?; + wallet_id = guard.seed_hash(); + guard + .platform_wallet + .clone() + .ok_or(TaskError::WalletNotFound)? }; - let asset_lock_proof = if let AssetLockProof::Instant(instant_asset_lock_proof) = - asset_lock_proof.as_ref() - { - // we need to make sure the instant send asset lock is recent - let tx_info = get_transaction_info(&sdk, &tx_id).await?; - - if tx_info.is_chain_locked && tx_info.height > 0 && tx_info.confirmations > 8 { - // Transaction is old enough that instant lock may have expired - let tx_block_height = tx_info.height; - - if tx_block_height <= metadata.core_chain_locked_height { - // Platform has verified this Core block, use chain lock proof - AssetLockProof::Chain(ChainAssetLockProof { - core_chain_locked_height: tx_block_height, - out_point: OutPoint::new(tx_id, 0), - }) - } else { - // Platform hasn't verified this Core block yet - return Err(TaskError::AssetLockExpired { - tx_block_height, - platform_height: metadata.core_chain_locked_height, - }); - } - } else { - AssetLockProof::Instant(instant_asset_lock_proof.clone()) - } - } else { - asset_lock_proof.as_ref().clone() - }; - (asset_lock_proof, private_key, tx_id) + + // platform-wallet handles IS→CL fallback and key re-derivation internally + let (asset_lock_proof, _private_key) = platform_wallet + .asset_locks() + .resume_asset_lock(&out_point, std::time::Duration::from_secs(300)) + .await + .map_err(|e| TaskError::AssetLockTransactionBuildFailed { + detail: e.to_string(), + })?; + + (asset_lock_proof, out_point) } RegisterIdentityFundingMethod::FundWithWallet(amount, identity_index) => { - // Scope the write lock to avoid holding it across an await. - // UTXOs are selected but NOT removed yet — removal happens after broadcast. - let (asset_lock_transaction, asset_lock_proof_private_key, _, used_utxos) = { - let mut wallet = wallet.write().map_err(TaskError::from)?; - wallet_id = wallet.seed_hash(); - match wallet.registration_asset_lock_transaction( - self, - sdk.network, - amount, - true, - identity_index, - None, - ) { - Ok(transaction) => transaction, - Err(e) => { - // Reload UTXOs (RPC: fetches from Core; SPV: no-op). - // Only retry if something actually changed. - if !wallet - .reload_utxos(self) - .map_err(|e| TaskError::UtxoUpdateFailed { detail: e })? - { - return Err(TaskError::AssetLockTransactionBuildFailed { - detail: e, - }); - } - wallet - .registration_asset_lock_transaction( - self, - sdk.network, - amount, - true, - identity_index, - None, - ) - .map_err(|e| TaskError::AssetLockTransactionBuildFailed { - detail: e, - })? - } - } + let platform_wallet = { + let guard = wallet.read().map_err(TaskError::from)?; + wallet_id = guard.seed_hash(); + guard + .platform_wallet + .clone() + .ok_or(TaskError::WalletNotFound)? }; - let tx_id = self - .broadcast_and_commit_asset_lock( - &asset_lock_transaction, + // Single call: builds asset lock TX, broadcasts, waits for + // finality proof (IS or CL), and returns the proof + key. + let (asset_lock_proof, _asset_lock_proof_private_key, out_point) = platform_wallet + .asset_locks() + .create_funded_asset_lock_proof( amount, - &wallet_id, - &wallet, - &used_utxos, + 0, + platform_wallet::AssetLockFundingType::IdentityRegistration, + identity_index, ) - .await?; - - let asset_lock_proof = self.wait_for_asset_lock_proof(tx_id).await?; + .await + .map_err(|e| TaskError::AssetLockTransactionBuildFailed { + detail: e.to_string(), + })?; - (asset_lock_proof, asset_lock_proof_private_key, tx_id) + (asset_lock_proof, out_point) } RegisterIdentityFundingMethod::FundWithPlatformAddresses { inputs, @@ -187,44 +127,6 @@ impl AppContext { ) .await; } - RegisterIdentityFundingMethod::FundWithUtxo( - utxo, - tx_out, - input_address, - identity_index, - ) => { - // Scope the write lock to avoid holding it across an await. - let (asset_lock_transaction, asset_lock_proof_private_key) = { - let mut wallet = wallet.write().map_err(TaskError::from)?; - wallet_id = wallet.seed_hash(); - wallet - .registration_asset_lock_transaction_for_utxo( - self, - sdk.network, - utxo, - tx_out.clone(), - input_address.clone(), - identity_index, - ) - .map_err(|e| TaskError::AssetLockTransactionBuildFailed { detail: e })? - }; - - let used_utxos = BTreeMap::from([(utxo, (tx_out.clone(), input_address.clone()))]); - - let tx_id = self - .broadcast_and_commit_asset_lock( - &asset_lock_transaction, - tx_out.value, - &wallet_id, - &wallet, - &used_utxos, - ) - .await?; - - let asset_lock_proof = self.wait_for_asset_lock_proof(tx_id).await?; - - (asset_lock_proof, asset_lock_proof_private_key, tx_id) - } }; let identity_id = asset_lock_proof @@ -292,6 +194,8 @@ impl AppContext { qualified_identity.alias = Some(alias_input); } + let tx_id = out_point.txid; + if let Some(existing_identity) = existing_identity { qualified_identity.identity = existing_identity; qualified_identity.status = IdentityStatus::Unknown; @@ -301,16 +205,6 @@ impl AppContext { &Some((wallet_id, wallet_identity_index)), )?; - { - let mut wallet = wallet.write().map_err(TaskError::from)?; - wallet - .unused_asset_locks - .retain(|(tx, _, _, _, _)| tx.txid() != tx_id); - wallet - .identities - .insert(wallet_identity_index, qualified_identity.identity.clone()); - } - self.db .set_asset_lock_identity_id(tx_id.as_byte_array(), identity_id.as_bytes())?; @@ -331,104 +225,34 @@ impl AppContext { identity_id.as_bytes(), )?; + // Use the one-call API which handles IS→CL fallback internally. + // The asset lock is already tracked by the manager from the funding + // phase above, so FromExistingAssetLock resumes it efficiently. match self .put_new_identity_to_platform( - &sdk, &identity, - asset_lock_proof.clone(), - &asset_lock_proof_private_key, + IdentityFunding::FromExistingAssetLock { out_point }, + wallet_identity_index, qualified_identity.clone(), + &wallet_id, ) .await { Ok(updated_identity) => { qualified_identity.identity = updated_identity; - qualified_identity.status = IdentityStatus::Unknown; // force refresh of the status + qualified_identity.status = IdentityStatus::Unknown; } Err(e) => { - if matches!(e, TaskError::AssetLockInstantLockProofInvalid { .. }) { - // Try to use chain asset lock proof instead - let tx_info = get_transaction_info(&sdk, &tx_id).await?; - - if tx_info.is_chain_locked && tx_info.height > 0 { - let tx_block_height = tx_info.height; - - if tx_block_height <= metadata.core_chain_locked_height { - // Platform has verified this Core block, use chain lock proof - let chain_asset_lock_proof = - AssetLockProof::Chain(ChainAssetLockProof { - core_chain_locked_height: tx_block_height, - out_point: OutPoint::new(tx_id, 0), - }); - - // Retry with chain asset lock proof - match self - .put_new_identity_to_platform( - &sdk, - &identity, - chain_asset_lock_proof, - &asset_lock_proof_private_key, - qualified_identity.clone(), - ) - .await - { - Ok(updated_identity) => { - qualified_identity.identity = updated_identity; - qualified_identity.status = IdentityStatus::Unknown; - } - Err(retry_err) => { - qualified_identity - .status - .update(IdentityStatus::FailedCreation); - - self.insert_local_qualified_identity( - &qualified_identity, - &Some((wallet_id, wallet_identity_index)), - )?; - - return Err(retry_err); - } - } - } else { - qualified_identity - .status - .update(IdentityStatus::FailedCreation); - - self.insert_local_qualified_identity( - &qualified_identity, - &Some((wallet_id, wallet_identity_index)), - )?; - - return Err(TaskError::AssetLockExpired { - tx_block_height, - platform_height: metadata.core_chain_locked_height, - }); - } - } else { - qualified_identity - .status - .update(IdentityStatus::FailedCreation); - - self.insert_local_qualified_identity( - &qualified_identity, - &Some((wallet_id, wallet_identity_index)), - )?; - - return Err(TaskError::AssetLockInstantLockExpiredNotChainlocked); - } - } else { - // we failed, set the status accordingly and terminate the process - qualified_identity - .status - .update(IdentityStatus::FailedCreation); + qualified_identity + .status + .update(IdentityStatus::FailedCreation); - self.insert_local_qualified_identity( - &qualified_identity, - &Some((wallet_id, wallet_identity_index)), - )?; + self.insert_local_qualified_identity( + &qualified_identity, + &Some((wallet_id, wallet_identity_index)), + )?; - return Err(e); - } + return Err(e); } } @@ -436,17 +260,36 @@ impl AppContext { &qualified_identity, &Some((wallet_id, wallet_identity_index)), )?; - { - let mut wallet = wallet.write().map_err(TaskError::from)?; - wallet - .unused_asset_locks - .retain(|(tx, _, _, _, _)| tx.txid() != tx_id); - wallet.identities.insert(wallet_identity_index, identity); - } self.db .set_asset_lock_identity_id(tx_id.as_byte_array(), identity_id.as_bytes())?; + // Stage an IdentityChangeSet capturing the confirmed identity and its + // balance so the changeset reflects the Platform confirmation. + if let Some(pw) = self.get_platform_wallet(&wallet_id) { + use platform_wallet::changeset::changeset::{ + IdentityChangeSet, IdentityEntry, PlatformWalletChangeSet, + }; + let changeset = PlatformWalletChangeSet { + identities: Some(IdentityChangeSet { + identities: BTreeMap::from([( + identity_id, + IdentityEntry { + identity: qualified_identity.identity.clone(), + identity_index: wallet_identity_index, + label: qualified_identity.alias.clone(), + last_updated_balance_block_time: None, + last_synced_keys_block_time: None, + dpns_names: vec![], + top_ups: Default::default(), + }, + )]), + }), + ..Default::default() + }; + pw.queue_persist(changeset); + } + let fee_result = FeeResult::new(estimated_fee, estimated_fee); Ok(BackendTaskSuccessResult::RegisteredIdentity( qualified_identity, @@ -456,61 +299,60 @@ impl AppContext { async fn put_new_identity_to_platform( &self, - sdk: &Sdk, identity: &Identity, - asset_lock_proof: AssetLockProof, - asset_lock_proof_private_key: &PrivateKey, + funding: IdentityFunding, + identity_index: u32, qualified_identity: QualifiedIdentity, + wallet_seed_hash: &[u8; 32], ) -> Result { - match identity - .put_to_platform_and_wait_for_response( - sdk, - asset_lock_proof.clone(), - asset_lock_proof_private_key, + // Use the one-call API which handles IS→CL fallback internally. + let platform_wallet = self + .get_platform_wallet(wallet_seed_hash) + .ok_or(TaskError::WalletNotFound)?; + + let result = platform_wallet + .identity() + .funded_register_identity( + identity, + funding.clone(), + identity_index, &qualified_identity, None, ) - .await - { + .await; + + match result { Ok(updated_identity) => Ok(updated_identity), - Err(e) => { - if matches!(e, Error::Protocol(ProtocolError::UnknownVersionError(_))) { - identity - .put_to_platform_and_wait_for_response( - sdk, - asset_lock_proof.clone(), - asset_lock_proof_private_key, - &qualified_identity, - None, - ) - .await - .map_err(|retry_err| { - let logged = self.log_drive_proof_error(retry_err, RequestType::BroadcastStateTransition); - // If the logged variant is ProofError, return it directly; - // otherwise log the reconstructed transition for debugging. - if matches!(logged, TaskError::ProofError { .. }) { - return logged; - } - if let Ok(transition) = IdentityCreateTransition::try_from_identity_with_signer( - identity, - asset_lock_proof, - asset_lock_proof_private_key.inner.as_ref(), - &qualified_identity, - &NativeBlsModule, - 0, - self.platform_version(), - ) { - tracing::debug!( - "Register identity retry failed; reconstructed transition: {:?}", - transition - ); - } - logged - }) - } else { - Err(self.log_drive_proof_error(e, RequestType::BroadcastStateTransition)) - } + Err(platform_wallet::PlatformWalletError::Sdk(ref e)) + if matches!(e, Error::Protocol(ProtocolError::UnknownVersionError(_))) => + { + // Retry once on version mismatch. + let retry_result = platform_wallet + .identity() + .funded_register_identity( + identity, + funding, + identity_index, + &qualified_identity, + None, + ) + .await; + + retry_result.map_err(|retry_err| match retry_err { + platform_wallet::PlatformWalletError::Sdk(sdk_err) => { + self.log_drive_proof_error(sdk_err, RequestType::BroadcastStateTransition) + } + other => TaskError::PlatformWallet { + source: Box::new(other), + }, + }) + } + Err(platform_wallet::PlatformWalletError::Sdk(e)) => { + Err(self.log_drive_proof_error(e, RequestType::BroadcastStateTransition)) } + Err(other) => Err(TaskError::PlatformWallet { + source: Box::new(other), + }), } } @@ -547,8 +389,15 @@ impl AppContext { key_count, ); - // Clone the wallet for use as the address signer (needed across async boundary) - let wallet_clone = { wallet.read().map_err(TaskError::from)?.clone() }; + // Get the platform wallet's address signer (PlatformAddressWallet implements Signer) + let platform_wallet = { + let wallet_guard = wallet.read().map_err(TaskError::from)?; + wallet_guard + .platform_wallet + .as_ref() + .cloned() + .ok_or(TaskError::WalletLocked)? + }; let identity = Identity::new_with_input_addresses_and_keys( &inputs, @@ -582,7 +431,14 @@ impl AppContext { // Send to Platform using address funding and wait for response match identity - .put_with_address_funding(&sdk, inputs, None, &qualified_identity, &wallet_clone, None) + .put_with_address_funding( + &sdk, + inputs, + None, + &qualified_identity, + platform_wallet.platform(), + None, + ) .await { Ok((updated_identity, address_infos)) => { @@ -601,13 +457,6 @@ impl AppContext { &Some((wallet_seed_hash, wallet_identity_index)), )?; - { - let mut wallet_guard = wallet.write().map_err(TaskError::from)?; - wallet_guard - .identities - .insert(wallet_identity_index, qualified_identity.identity.clone()); - } - let fee_result = FeeResult::new(estimated_fee, estimated_fee); Ok(BackendTaskSuccessResult::RegisteredIdentity( qualified_identity, @@ -635,13 +484,13 @@ impl AppContext { /// Get the best (most recent nonce) AddressInfo from all wallets for the given [PlatformAddress] in current [Self::network]. /// - /// Returns `None`` if no info is found. + /// Returns `None` if no info is found. fn get_platform_address_best_info( &self, platform_address: &PlatformAddress, network: Network, ) -> Option { - let generic_address = platform_address.to_address_with_network(network); + let core_addr = platform_address.to_address_with_network(network); let wallets = self .wallets .read() @@ -651,17 +500,20 @@ impl AppContext { let mut recent_info: Option = None; for wallet in wallets.values() { let wallet_guard = wallet.read().ok()?; - - if let Some(new_info) = wallet_guard.get_platform_address_info(&generic_address) - && recent_info - .as_ref() - .is_none_or(|recent| new_info.nonce > recent.nonce) + if let Ok(Some((balance, nonce))) = + self.db + .get_platform_address_info(&wallet_guard.seed_hash(), &core_addr, &network) { - recent_info = Some(AddressInfo { - address: *platform_address, - balance: new_info.balance, - nonce: new_info.nonce, - }); + if recent_info + .as_ref() + .is_none_or(|recent| nonce > recent.nonce) + { + recent_info = Some(AddressInfo { + address: *platform_address, + balance, + nonce, + }); + } } } diff --git a/src/backend_task/identity/top_up_identity.rs b/src/backend_task/identity/top_up_identity.rs index 6916074d4..ece83630d 100644 --- a/src/backend_task/identity/top_up_identity.rs +++ b/src/backend_task/identity/top_up_identity.rs @@ -1,21 +1,14 @@ use crate::backend_task::error::TaskError; use crate::backend_task::identity::{IdentityTopUpInfo, TopUpIdentityFundingMethod}; use crate::backend_task::{BackendTaskSuccessResult, FeeResult}; -use crate::context::{AppContext, get_transaction_info}; +use crate::context::AppContext; use crate::model::fee_estimation::PlatformFeeEstimator; use crate::model::proof_log_item::RequestType; +use crate::platform_wallet_bridge::IdentityFunding; use dash_sdk::Error; use dash_sdk::dpp::ProtocolError; -use dash_sdk::dpp::block::extended_epoch_info::ExtendedEpochInfo; -use dash_sdk::dpp::dashcore::OutPoint; use dash_sdk::dpp::dashcore::hashes::Hash; use dash_sdk::dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; -use dash_sdk::dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; -use dash_sdk::dpp::prelude::AssetLockProof; -use dash_sdk::dpp::state_transition::identity_topup_transition::IdentityTopUpTransition; -use dash_sdk::dpp::state_transition::identity_topup_transition::methods::IdentityTopUpTransitionMethodsV0; -use dash_sdk::platform::Fetch; -use dash_sdk::platform::transition::top_up_identity::TopUpIdentity; use std::collections::BTreeMap; impl AppContext { @@ -29,192 +22,53 @@ impl AppContext { identity_funding_method, } = input; - let sdk = self.sdk.load().as_ref().clone(); - - let (_, metadata) = ExtendedEpochInfo::fetch_with_metadata(&sdk, 0, None).await?; - - let (asset_lock_proof, asset_lock_proof_private_key, tx_id, top_up_index) = - match identity_funding_method { - TopUpIdentityFundingMethod::UseAssetLock( - address, - asset_lock_proof, - transaction, - ) => { - let tx_id = transaction.txid(); - - // Scope the read guard so it's dropped before the async DAPI call below - let private_key = { - let wallet = wallet.read().map_err(TaskError::from)?; - wallet - .private_key_for_address(&address, self.network) - .map_err(|e| TaskError::WalletKeyLookupFailed { detail: e })? - .ok_or(TaskError::AssetLockNotValidForWallet)? - }; - let asset_lock_proof = - if let AssetLockProof::Instant(instant_asset_lock_proof) = - asset_lock_proof.as_ref() - { - // we need to make sure the instant send asset lock is recent - let tx_info = get_transaction_info(&sdk, &tx_id).await?; - - if tx_info.is_chain_locked - && tx_info.height > 0 - && tx_info.confirmations > 8 - { - // Transaction is old enough that instant lock may have expired - let tx_block_height = tx_info.height; - - if tx_block_height <= metadata.core_chain_locked_height { - // Platform has verified this Core block, use chain lock proof - AssetLockProof::Chain(ChainAssetLockProof { - core_chain_locked_height: tx_block_height, - out_point: OutPoint::new(tx_id, 0), - }) - } else { - // Platform hasn't verified this Core block yet - return Err(TaskError::AssetLockExpired { - tx_block_height, - platform_height: metadata.core_chain_locked_height, - }); - } - } else { - AssetLockProof::Instant(instant_asset_lock_proof.clone()) - } - } else { - asset_lock_proof.as_ref().clone() - }; - (asset_lock_proof, private_key, tx_id, None) - } - TopUpIdentityFundingMethod::FundWithWallet( - amount, - identity_index, - top_up_index, - ) => { - // Scope the write lock to avoid holding it across an await. - // UTXOs are selected but NOT removed yet — removal happens after broadcast. - let ( - asset_lock_transaction, - asset_lock_proof_private_key, - _, - used_utxos, - wallet_seed_hash, - ) = { - let mut wallet = wallet.write().map_err(TaskError::from)?; - let seed_hash = wallet.seed_hash(); - let tx_result = match wallet.top_up_asset_lock_transaction( - self, - sdk.network, + let (out_point, identity_index, top_up_index) = match identity_funding_method { + TopUpIdentityFundingMethod::UseAssetLock(out_point) => { + let _platform_wallet = { + let guard = wallet.read().map_err(TaskError::from)?; + guard + .platform_wallet + .clone() + .ok_or(TaskError::WalletNotFound)? + }; + + (out_point, 0u32, None) + } + TopUpIdentityFundingMethod::FundWithWallet( + amount, + identity_index, + top_up_index, + ) => { + let platform_wallet = { + let guard = wallet.read().map_err(TaskError::from)?; + guard + .platform_wallet + .clone() + .ok_or(TaskError::WalletNotFound)? + }; + + // Single call: builds asset lock TX, broadcasts, waits for + // finality proof (IS or CL), and returns the proof + key. + // The lock is tracked by AssetLockManager for later resumption. + let (_asset_lock_proof, _asset_lock_proof_private_key, out_point) = + platform_wallet + .asset_locks() + .create_funded_asset_lock_proof( amount, - true, + 0, + platform_wallet::AssetLockFundingType::IdentityTopUp, identity_index, - top_up_index, - None, - ) { - Ok(transaction) => transaction, - Err(e) => { - // Reload UTXOs (RPC: fetches from Core; SPV: no-op). - // Only retry if something actually changed. - if !wallet - .reload_utxos(self) - .map_err(|e| TaskError::UtxoUpdateFailed { detail: e })? - { - return Err(TaskError::AssetLockTransactionBuildFailed { - detail: e, - }); - } - wallet - .top_up_asset_lock_transaction( - self, - sdk.network, - amount, - true, - identity_index, - top_up_index, - None, - ) - .map_err(|e| TaskError::AssetLockTransactionBuildFailed { - detail: e, - })? - } - }; - ( - tx_result.0, - tx_result.1, - tx_result.2, - tx_result.3, - seed_hash, ) - }; - - let tx_id = self - .broadcast_and_commit_asset_lock( - &asset_lock_transaction, - amount, - &wallet_seed_hash, - &wallet, - &used_utxos, - ) - .await?; - - let asset_lock_proof = self.wait_for_asset_lock_proof(tx_id).await?; - - ( - asset_lock_proof, - asset_lock_proof_private_key, - tx_id, - Some((amount, top_up_index)), - ) - } - TopUpIdentityFundingMethod::FundWithUtxo( - utxo, - tx_out, - input_address, - identity_index, - top_up_index, - ) => { - // Scope the write lock to avoid holding it across an await. - let (asset_lock_transaction, asset_lock_proof_private_key, wallet_seed_hash) = { - let mut wallet = wallet.write().map_err(TaskError::from)?; - let seed_hash = wallet.seed_hash(); - let tx_result = wallet - .top_up_asset_lock_transaction_for_utxo( - self, - sdk.network, - utxo, - tx_out.clone(), - input_address.clone(), - identity_index, - top_up_index, - ) - .map_err(|e| TaskError::AssetLockTransactionBuildFailed { - detail: e, - })?; - (tx_result.0, tx_result.1, seed_hash) - }; - - let used_utxos = - BTreeMap::from([(utxo, (tx_out.clone(), input_address.clone()))]); - - let tx_id = self - .broadcast_and_commit_asset_lock( - &asset_lock_transaction, - tx_out.value, - &wallet_seed_hash, - &wallet, - &used_utxos, - ) - .await?; + .await + .map_err(|e| TaskError::AssetLockTransactionBuildFailed { + detail: e.to_string(), + })?; - let asset_lock_proof = self.wait_for_asset_lock_proof(tx_id).await?; + (out_point, identity_index, Some((amount, top_up_index))) + } + }; - ( - asset_lock_proof, - asset_lock_proof_private_key, - tx_id, - Some((tx_out.value, top_up_index)), - ) - } - }; + let tx_id = out_point.txid; self.db .set_asset_lock_identity_id_before_confirmation_by_network( @@ -226,100 +80,62 @@ impl AppContext { let balance_before = qualified_identity.identity.balance(); let estimated_fee = PlatformFeeEstimator::new().estimate_identity_topup(); - let updated_identity_balance = match qualified_identity - .identity - .top_up_identity( - &sdk, - asset_lock_proof.clone(), - &asset_lock_proof_private_key, - None, - None, - ) - .await - { - Ok(updated_identity) => updated_identity, - Err(e) => { - if crate::backend_task::error::is_instant_lock_proof_invalid(&e) { - // Try to use chain asset lock proof instead - let tx_info = get_transaction_info(&sdk, &tx_id).await?; + // Use the one-call API which handles IS→CL fallback internally. + // The asset lock is already tracked by the manager from the funding + // phase above, so FromExistingAssetLock resumes it efficiently. + let maybe_platform_wallet = self.platform_wallet_for_identity(&qualified_identity).ok(); - if tx_info.is_chain_locked && tx_info.height > 0 { - let tx_block_height = tx_info.height; + let platform_wallet = maybe_platform_wallet + .as_ref() + .ok_or(TaskError::WalletNotFound)?; - if tx_block_height <= metadata.core_chain_locked_height { - // Platform has verified this Core block, use chain lock proof - let chain_asset_lock_proof = - AssetLockProof::Chain(ChainAssetLockProof { - core_chain_locked_height: tx_block_height, - out_point: OutPoint::new(tx_id, 0), - }); + let funding = IdentityFunding::FromExistingAssetLock { out_point }; - // Retry with chain asset lock proof - qualified_identity - .identity - .top_up_identity( - &sdk, - chain_asset_lock_proof, - &asset_lock_proof_private_key, - None, - None, - ) - .await - .map_err(|e| { - self.log_drive_proof_error( - e, - RequestType::BroadcastStateTransition, - ) - })? - } else { - return Err(TaskError::AssetLockExpired { - tx_block_height, - platform_height: metadata.core_chain_locked_height, - }); - } - } else { - return Err(TaskError::AssetLockInstantLockExpiredNotChainlocked); - } - } else if matches!(e, Error::Protocol(ProtocolError::UnknownVersionError(_))) { - qualified_identity - .identity - .top_up_identity( - &sdk, - asset_lock_proof.clone(), - &asset_lock_proof_private_key, - None, - None, - ) - .await - .map_err(|retry_err| { - let logged = self.log_drive_proof_error( - retry_err, + let top_up_result = platform_wallet + .identity() + .funded_top_up_identity( + &qualified_identity.identity, + funding.clone(), + identity_index, + None, + ) + .await; + + let updated_identity_balance = match top_up_result { + Ok(new_balance) => new_balance, + Err(platform_wallet::PlatformWalletError::Sdk(ref e)) + if matches!(e, Error::Protocol(ProtocolError::UnknownVersionError(_))) => + { + // Retry once on version mismatch. + platform_wallet + .identity() + .funded_top_up_identity( + &qualified_identity.identity, + funding, + identity_index, + None, + ) + .await + .map_err(|retry_err| match retry_err { + platform_wallet::PlatformWalletError::Sdk(sdk_err) => self + .log_drive_proof_error( + sdk_err, RequestType::BroadcastStateTransition, - ); - if matches!(logged, TaskError::ProofError { .. }) { - return logged; - } - // Log the reconstructed transition for debugging before returning the error. - if let Ok(transition) = IdentityTopUpTransition::try_from_identity( - &qualified_identity.identity, - asset_lock_proof, - asset_lock_proof_private_key.inner.as_ref(), - 0, - self.platform_version(), - None, - ) { - tracing::debug!( - "Top-up retry failed; reconstructed transition: {:?}", - transition - ); - } - logged - })? - } else { - return Err( - self.log_drive_proof_error(e, RequestType::BroadcastStateTransition) - ); - } + ), + other => TaskError::PlatformWallet { + source: Box::new(other), + }, + })? + } + Err(platform_wallet::PlatformWalletError::Sdk(e)) => { + return Err( + self.log_drive_proof_error(e, RequestType::BroadcastStateTransition) + ); + } + Err(other) => { + return Err(TaskError::PlatformWallet { + source: Box::new(other), + }); } }; @@ -366,11 +182,38 @@ impl AppContext { self.update_local_qualified_identity(&qualified_identity)?; - { - let mut wallet = wallet.write().map_err(TaskError::from)?; - wallet - .unused_asset_locks - .retain(|(tx, _, _, _, _)| tx.txid() != tx_id); + // Stage an IdentityChangeSet capturing the updated identity balance + // so the changeset reflects the Platform confirmation. + if let Some(ref pw) = maybe_platform_wallet { + use platform_wallet::changeset::changeset::{ + IdentityChangeSet, IdentityEntry, PlatformWalletChangeSet, + }; + let changeset = PlatformWalletChangeSet { + identities: Some(IdentityChangeSet { + identities: BTreeMap::from([( + qualified_identity.identity.id(), + IdentityEntry { + identity: qualified_identity.identity.clone(), + identity_index: qualified_identity.wallet_index.unwrap_or(0), + label: qualified_identity.alias.clone(), + last_updated_balance_block_time: None, + last_synced_keys_block_time: None, + dpns_names: qualified_identity + .dpns_names + .iter() + .map(|n| n.name.clone()) + .collect(), + top_ups: if let Some((amount, idx)) = top_up_index { + BTreeMap::from([(idx, amount)]) + } else { + Default::default() + }, + }, + )]), + }), + ..Default::default() + }; + pw.queue_persist(changeset); } self.db.set_asset_lock_identity_id( diff --git a/src/backend_task/identity/transfer.rs b/src/backend_task/identity/transfer.rs index b660729d0..0be6fc9c6 100644 --- a/src/backend_task/identity/transfer.rs +++ b/src/backend_task/identity/transfer.rs @@ -7,7 +7,6 @@ use dash_sdk::dpp::fee::Credits; use dash_sdk::dpp::identity::KeyID; use dash_sdk::dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; use dash_sdk::platform::Identifier; -use dash_sdk::platform::transition::transfer::TransferToIdentity; use super::BackendTaskSuccessResult; @@ -19,19 +18,21 @@ impl AppContext { credits: Credits, id: Option, ) -> Result { - let sdk = self.sdk.load().as_ref().clone(); - let balance_before = qualified_identity.identity.balance(); let estimated_fee = PlatformFeeEstimator::new().estimate_credit_transfer(); - let (sender_balance, receiver_balance) = qualified_identity - .identity - .clone() - .transfer_credits( - &sdk, + let signing_key = + id.and_then(|key_id| qualified_identity.identity.get_public_key_by_id(key_id)); + + let platform_wallet = self.platform_wallet_for_identity(&qualified_identity)?; + let identity_wallet = platform_wallet.identity(); + + let (sender_balance, receiver_balance) = identity_wallet + .transfer_credits_with_signer( + &qualified_identity.identity, to_identifier, credits, - id.and_then(|key_id| qualified_identity.identity.get_public_key_by_id(key_id)), + signing_key, qualified_identity.clone(), None, ) diff --git a/src/backend_task/identity/withdraw_from_identity.rs b/src/backend_task/identity/withdraw_from_identity.rs index 57df40f6a..07e75f9bc 100644 --- a/src/backend_task/identity/withdraw_from_identity.rs +++ b/src/backend_task/identity/withdraw_from_identity.rs @@ -9,7 +9,6 @@ use dash_sdk::dpp::identity::KeyID; use dash_sdk::dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dash_sdk::dpp::platform_value::string_encoding::Encoding; -use dash_sdk::platform::transition::withdraw_from_identity::WithdrawFromIdentity; use dash_sdk::platform::{Fetch, Identity}; use super::BackendTaskSuccessResult; @@ -76,14 +75,14 @@ impl AppContext { let balance_before = qualified_identity.identity.balance(); let estimated_fee = PlatformFeeEstimator::new().estimate_credit_withdrawal(); - let remaining_balance = qualified_identity - .identity - .clone() - .withdraw( - &sdk, + let platform_wallet = self.platform_wallet_for_identity(&qualified_identity)?; + let identity_wallet = platform_wallet.identity(); + + let remaining_balance = identity_wallet + .withdraw_credits_with_signer( + &qualified_identity.identity, to_address, credits, - Some(1), signing_key, qualified_identity.clone(), None, diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index 7d16293ce..b9f444ce4 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -10,6 +10,7 @@ use crate::backend_task::platform_info::{PlatformInfoTaskRequestType, PlatformIn use crate::backend_task::system_task::SystemTask; use crate::backend_task::wallet::WalletTask; use crate::context::AppContext; +use crate::platform_wallet_bridge::CoreAddressInfo; use dash_sdk::dpp::dashcore::Address; use dash_sdk::dpp::dashcore::address::NetworkChecked; use dash_sdk::dpp::dashcore::Network; @@ -38,10 +39,33 @@ use dash_sdk::platform::{Document, Identifier}; use dash_sdk::query_types::{Documents, IndexMap}; use futures::future::join_all; use std::collections::BTreeMap; +use std::future::Future; +use std::pin::Pin; use std::sync::Arc; +use std::task::{Context as TaskContext, Poll}; use shielded::ShieldedTask; -use tokens::TokenTask; + +/// Wrapper to assert that a future is `Send`. +/// +/// This is safe when the future only borrows owned data that lives within +/// the same `async` block (e.g. a cloned `Sdk` or `Arc`). +/// The compiler cannot prove `Send` in these cases due to higher-ranked +/// trait-bound (HRTB) limitations with `async fn` that take references. +struct AssertSend(F); +// SAFETY: The futures wrapped here only borrow data owned by the enclosing +// `async move` block (`sdk: Sdk`, `this: Arc`). Those values are +// `Send` and live for the entire duration of the future, so the borrows are +// safe to send across threads. +unsafe impl Send for AssertSend {} +impl Future for AssertSend { + type Output = F::Output; + fn poll(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll { + // SAFETY: We are not moving the inner future, just projecting the pin. + unsafe { self.map_unchecked_mut(|s| &mut s.0) }.poll(cx) + } +} use grovestark::GroveSTARKTask; +use tokens::TokenTask; pub mod broadcast_state_transition; pub mod contested_names; @@ -193,6 +217,8 @@ pub enum BackendTaskSuccessResult { seed_hash: WalletSeedHash, address: String, }, + /// Per-address info loaded from the CoreWallet bridge + AddressInfo(Vec), /// Platform address balances fetched from Platform PlatformAddressBalances { seed_hash: WalletSeedHash, @@ -344,104 +370,117 @@ impl BackendTaskSuccessResult {} impl AppContext { /// Run backend tasks sequentially - pub async fn run_backend_tasks_sequential( + pub fn run_backend_tasks_sequential( self: &Arc, tasks: Vec, sender: SenderAsync, - ) -> Vec> { - let mut results = Vec::new(); - for task in tasks { - match self.run_backend_task(task, sender.clone()).await { - Ok(result) => results.push(Ok(result)), - Err(e) => results.push(Err(e)), - }; - } - results + ) -> Pin>> + Send>> + { + let this = Arc::clone(self); + Box::pin(async move { + let mut results = Vec::new(); + for task in tasks { + match this.run_backend_task(task, sender.clone()).await { + Ok(result) => results.push(Ok(result)), + Err(e) => results.push(Err(e)), + }; + } + results + }) } /// Run backend tasks concurrently - pub async fn run_backend_tasks_concurrent( + pub fn run_backend_tasks_concurrent( self: &Arc, tasks: Vec, sender: SenderAsync, - ) -> Vec> { - let futures = tasks - .into_iter() - .map(|task| { - let cloned_self = Arc::clone(self); - let cloned_sender = sender.clone(); - async move { cloned_self.run_backend_task(task, cloned_sender).await } - }) - .collect::>(); + ) -> Pin>> + Send>> + { + let this = Arc::clone(self); + Box::pin(async move { + let futures = tasks + .into_iter() + .map(|task| { + let cloned_self = Arc::clone(&this); + let cloned_sender = sender.clone(); + async move { cloned_self.run_backend_task(task, cloned_sender).await } + }) + .collect::>(); - // Wait for all to finish before returning - join_all(futures).await + join_all(futures).await + }) } - pub async fn run_backend_task( + pub fn run_backend_task( self: &Arc, task: BackendTask, sender: SenderAsync, - ) -> Result { - let sdk = self.sdk.load().as_ref().clone(); - match task { - BackendTask::ContractTask(contract_task) => { - 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) => { - Ok(self.run_identity_task(identity_task, &sdk, sender).await?) + ) -> Pin> + Send>> { + let this = Arc::clone(self); + Box::pin(AssertSend(async move { + let sdk = this.sdk.load().as_ref().clone(); + match task { + BackendTask::ContractTask(contract_task) => { + Ok(this.run_contract_task(*contract_task, &sdk, sender).await?) + } + BackendTask::ContestedResourceTask(contested_resource_task) => Ok(this + .run_contested_resource_task(contested_resource_task, &sdk, sender) + .await?), + BackendTask::IdentityTask(identity_task) => { + Ok(this.run_identity_task(identity_task, &sdk, sender).await?) + } + BackendTask::DocumentTask(document_task) => { + Ok(this.run_document_task(*document_task, &sdk).await?) + } + BackendTask::CoreTask(core_task) => Ok(this.run_core_task(core_task).await?), + BackendTask::DashPayTask(dashpay_task) => { + Ok(this.run_dashpay_task(*dashpay_task, &sdk).await?) + } + BackendTask::BroadcastStateTransition(state_transition) => Ok(this + .broadcast_state_transition(state_transition, &sdk) + .await?), + BackendTask::TokenTask(token_task) => { + Ok(this.run_token_task(*token_task, &sdk, sender).await?) + } + BackendTask::SystemTask(system_task) => { + Ok(this.run_system_task(system_task, sender).await?) + } + BackendTask::MnListTask(mnlist_task) => { + Ok(mnlist::run_mnlist_task(&this, mnlist_task).await?) + } + BackendTask::PlatformInfo(platform_info_task) => Ok(this + .run_platform_info_task(platform_info_task, &sdk) + .await?), + BackendTask::GroveSTARKTask(grovestark_task) => { + Ok(grovestark::run_grovestark_task(grovestark_task, &sdk).await?) + } + BackendTask::WalletTask(wallet_task) => { + Ok(this.run_wallet_task(wallet_task).await?) + } + BackendTask::ShieldedTask(shielded_task) => { + Ok(this.run_shielded_task(shielded_task).await?) + } + BackendTask::DiscoverDapiNodes { network } => { + let devnet_name = this + .config + .read() + .map_err(|_| TaskError::LockPoisoned { + resource: "NetworkConfig", + })? + .devnet_name + .clone(); + let (count, addresses_csv) = + dapi_discovery::discover_and_format(network, devnet_name.as_deref()) + .await?; + Ok(BackendTaskSuccessResult::DapiNodesDiscovered { + network, + count, + addresses_csv, + }) + } + BackendTask::None => Ok(BackendTaskSuccessResult::None), } - BackendTask::DocumentTask(document_task) => { - Ok(self.run_document_task(*document_task, &sdk).await?) - } - BackendTask::CoreTask(core_task) => Ok(self.run_core_task(core_task).await?), - BackendTask::DashPayTask(dashpay_task) => { - 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) => { - Ok(self.run_token_task(*token_task, &sdk, sender).await?) - } - BackendTask::SystemTask(system_task) => { - Ok(self.run_system_task(system_task, sender).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) => { - 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::DiscoverDapiNodes { network } => { - let devnet_name = self - .config - .read() - .map_err(|_| TaskError::LockPoisoned { - resource: "NetworkConfig", - })? - .devnet_name - .clone(); - let (count, addresses_csv) = - dapi_discovery::discover_and_format(network, devnet_name.as_deref()).await?; - Ok(BackendTaskSuccessResult::DapiNodesDiscovered { - network, - count, - addresses_csv, - }) - } - BackendTask::None => Ok(BackendTaskSuccessResult::None), - } + })) } async fn run_wallet_task( @@ -508,6 +547,12 @@ impl AppContext { ) .await } + WalletTask::LoadAddressInfo { seed_hash } => { + let platform_wallet = self.require_platform_wallet(&seed_hash)?; + let wallet_info = platform_wallet.state().await; + let info = CoreAddressInfo::all_from_wallet_info(wallet_info.managed_state.wallet_info()); + Ok(BackendTaskSuccessResult::AddressInfo(info)) + } } } } diff --git a/src/backend_task/shielded/bundle.rs b/src/backend_task/shielded/bundle.rs index a0fffbe71..8655651c2 100644 --- a/src/backend_task/shielded/bundle.rs +++ b/src/backend_task/shielded/bundle.rs @@ -115,13 +115,17 @@ pub fn build_shield_credit( vec![AddressFundsFeeStrategyStep::DeductFromInput(0)]; let wallet = wallet_arc.read()?; + let platform_wallet = wallet + .platform_wallet + .as_ref() + .ok_or(TaskError::WalletLocked)?; // memo: 36-byte structured memo (4-byte type tag + 32-byte payload); all zeros = empty memo build_shield_transition( &recipient_addr, amount, inputs, fee_strategy, - &*wallet, + platform_wallet.platform(), 0, &prover, [0u8; 36], @@ -163,18 +167,13 @@ pub async fn shield_credits( n } else { let wallet = wallet_arc.read()?; - 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) - } else { - None - } - }) - .ok_or(TaskError::PlatformAddressNotFound)? + let core_addr = from_address.to_address_with_network(app_context.network); + let (_balance, db_nonce) = app_context + .db + .get_platform_address_info(&wallet.seed_hash(), &core_addr, &app_context.network) + .map_err(|_| TaskError::PlatformAddressNotFound)? + .ok_or(TaskError::PlatformAddressNotFound)?; + db_nonce + 1 }; let mut inputs = BTreeMap::new(); @@ -196,13 +195,17 @@ pub async fn shield_credits( let state_transition = { let wallet = wallet_arc.read()?; + let platform_wallet = wallet + .platform_wallet + .as_ref() + .ok_or(TaskError::WalletLocked)?; // memo: 36-byte structured memo (4-byte type tag + 32-byte payload); all zeros = empty memo build_shield_transition( &recipient_addr, amount, inputs, fee_strategy, - &*wallet, + platform_wallet.platform(), 0, &prover, [0u8; 36], @@ -429,9 +432,7 @@ pub async fn shield_from_asset_lock( amount_duffs: u64, source_address: Option<&Address>, ) -> 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 std::time::Duration; @@ -443,7 +444,7 @@ pub async fn shield_from_asset_lock( 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 platform_wallet = { let wallet_arc = { let wallets = app_context.wallets.read()?; wallets @@ -452,126 +453,48 @@ pub async fn shield_from_asset_lock( .ok_or(TaskError::WalletNotFound)? }; - let mut wallet = wallet_arc - .write() + let wallet = wallet_arc + .read() .map_err(|_| TaskError::LockPoisoned { resource: "wallet" })?; - let first_result = wallet.generic_asset_lock_transaction( - app_context.as_ref(), - app_context.network, - asset_lock_duffs, - false, - source_address, - ); - - let (tx, private_key, address, _change, utxos) = match first_result { - Ok(ok) => ok, - Err(_) => { - wallet - .reload_utxos(app_context.as_ref()) - .map_err(|detail| TaskError::WalletUtxoReloadFailed { detail })?; - wallet - .generic_asset_lock_transaction( - app_context.as_ref(), - app_context.network, - asset_lock_duffs, - false, - source_address, - ) - .map_err(shielded_build_error)? - } - }; - - (tx, private_key, address, utxos) + wallet + .platform_wallet + .clone() + .ok_or(TaskError::WalletNotFound)? }; - 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()?; - proofs.insert(tx_id, None); - } - - // Step 3: Broadcast the transaction - app_context - .core_client - .read() - .map_err(|_| TaskError::LockPoisoned { - resource: "core_client", - })? - .send_raw_transaction(&asset_lock_transaction)?; - - // Step 4: Remove used UTXOs from wallet - { - let wallet_arc = { - let wallets = app_context.wallets.read()?; - wallets - .get(seed_hash) - .cloned() - .ok_or(TaskError::WalletNotFound)? - }; - - 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() - }); - - for utxo in used_utxos.keys() { - app_context - .db - .drop_utxo(utxo, &app_context.network.to_string())?; - } - - wallet - .recalculate_affected_address_balances(&used_utxos, app_context.as_ref()) - .map_err(|detail| TaskError::WalletBalanceRecalculationFailed { detail })?; - } + let (asset_lock_transaction, _asset_lock_private_key) = platform_wallet + .asset_locks() + .build_asset_lock_transaction( + asset_lock_duffs, + 0, + platform_wallet::AssetLockFundingType::IdentityRegistration, + 0, + ) + .await + .map_err(|e| shielded_build_error(e.to_string()))?; - // 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)); - 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(TaskError::ShieldedAssetLockTimeout); - } - _ = tokio::time::sleep(Duration::from_millis(200)) => { - let proofs = app_context.transactions_waiting_for_finality.lock()?; - if let Some(Some(proof)) = proofs.get(&tx_id) { - asset_lock_proof = proof.clone(); - break; - } - } - } - } + let tx_id = asset_lock_transaction.txid(); + let out_point = dash_sdk::dpp::dashcore::OutPoint::new(tx_id, 0); + + // Step 2–5: Register with AssetLockManager, broadcast via DAPI, and wait + // for finality proof (IS-lock or ChainLock). The manager handles the full + // lifecycle internally via SPV event subscription. + platform_wallet.asset_locks().recover_asset_lock_blocking( + asset_lock_transaction.clone(), + asset_lock_duffs, + 0, + platform_wallet::AssetLockFundingType::IdentityRegistration, + 0, + out_point, + None, + ); - // Step 6: Clean up the finality tracking - { - let mut proofs = app_context.transactions_waiting_for_finality.lock()?; - proofs.remove(&tx_id); - } + let (asset_lock_proof, asset_lock_private_key) = platform_wallet + .asset_locks() + .resume_asset_lock(&out_point, Duration::from_secs(300)) + .await + .map_err(|e| shielded_build_error(e.to_string()))?; // Step 7: Build and broadcast the shield-from-asset-lock transition let sdk = { app_context.sdk.load().as_ref().clone() }; diff --git a/src/backend_task/tokens/burn_tokens.rs b/src/backend_task/tokens/burn_tokens.rs index 636ea91d0..ed1b9ba80 100644 --- a/src/backend_task/tokens/burn_tokens.rs +++ b/src/backend_task/tokens/burn_tokens.rs @@ -11,7 +11,6 @@ use dash_sdk::dpp::group::GroupStateTransitionInfoStatus; use dash_sdk::dpp::group::group_action_status::GroupActionStatus; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::platform_value::Value; -use dash_sdk::platform::tokens::builders::burn::TokenBurnTransitionBuilder; use dash_sdk::platform::tokens::transitions::BurnResult; use dash_sdk::platform::{DataContract, Identifier, IdentityPublicKey}; use std::sync::Arc; @@ -27,31 +26,24 @@ impl AppContext { public_note: Option, amount: u64, group_info: Option, - sdk: &Sdk, + _sdk: &Sdk, _sender: crate::utils::egui_mpsc::SenderAsync, ) -> Result { - let mut builder = TokenBurnTransitionBuilder::new( - data_contract.clone(), - token_position, - owner_identity.identity.id(), - amount, - ); + let platform_wallet = self.platform_wallet_for_identity(owner_identity)?; + let token_wallet = platform_wallet.tokens(); - if let Some(note) = public_note { - builder = builder.with_public_note(note); - } - - if let Some(group_info) = group_info { - builder = builder.with_using_group_info(group_info); - } - - let maybe_options = self.state_transition_options(); - if let Some(options) = maybe_options { - builder = builder.with_state_transition_creation_options(options); - } - - let result = sdk - .token_burn(builder, &signing_key, owner_identity) + let result = token_wallet + .burn_with_signer( + data_contract.clone(), + token_position, + owner_identity.identity.id(), + amount, + &signing_key, + owner_identity, + public_note, + group_info, + self.state_transition_options(), + ) .await .map_err(|e| self.log_drive_proof_error(e, RequestType::BroadcastStateTransition))?; diff --git a/src/backend_task/tokens/claim_tokens.rs b/src/backend_task/tokens/claim_tokens.rs index ca5542630..e0777c350 100644 --- a/src/backend_task/tokens/claim_tokens.rs +++ b/src/backend_task/tokens/claim_tokens.rs @@ -9,7 +9,6 @@ use dash_sdk::dpp::data_contract::associated_token::token_distribution_key::Toke use dash_sdk::dpp::document::DocumentV0Getters; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::platform_value::Value; -use dash_sdk::platform::tokens::builders::claim::TokenClaimTransitionBuilder; use dash_sdk::platform::tokens::transitions::ClaimResult; use dash_sdk::platform::{DataContract, Identifier, IdentityPublicKey}; use std::sync::Arc; @@ -24,27 +23,22 @@ impl AppContext { distribution_type: TokenDistributionType, signing_key: IdentityPublicKey, public_note: Option, - sdk: &Sdk, + _sdk: &Sdk, ) -> Result { - // Build - let mut builder = TokenClaimTransitionBuilder::new( - data_contract.clone(), - token_position, - actor_identity.identity.id(), - distribution_type, - ); + let platform_wallet = self.platform_wallet_for_identity(actor_identity)?; + let token_wallet = platform_wallet.tokens(); - if let Some(note) = public_note { - builder = builder.with_public_note(note); - } - - let maybe_options = self.state_transition_options(); - if let Some(options) = maybe_options { - builder = builder.with_state_transition_creation_options(options); - } - - let result = sdk - .token_claim(builder, &signing_key, actor_identity) + let result = token_wallet + .claim_with_signer( + data_contract.clone(), + token_position, + actor_identity.identity.id(), + distribution_type, + &signing_key, + actor_identity, + public_note, + self.state_transition_options(), + ) .await .map_err(|e| self.log_drive_proof_error(e, RequestType::BroadcastStateTransition))?; diff --git a/src/backend_task/tokens/destroy_frozen_funds.rs b/src/backend_task/tokens/destroy_frozen_funds.rs index d9400fbde..0637ff7b1 100644 --- a/src/backend_task/tokens/destroy_frozen_funds.rs +++ b/src/backend_task/tokens/destroy_frozen_funds.rs @@ -7,9 +7,6 @@ use crate::model::qualified_identity::QualifiedIdentity; use dash_sdk::Sdk; use dash_sdk::dpp::group::GroupStateTransitionInfoStatus; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; -use dash_sdk::dpp::state_transition::proof_result::StateTransitionProofResult; -use dash_sdk::platform::tokens::builders::destroy::TokenDestroyFrozenFundsTransitionBuilder; -use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; use dash_sdk::platform::{DataContract, Identifier, IdentityPublicKey}; use std::sync::Arc; @@ -24,41 +21,29 @@ impl AppContext { public_note: Option, frozen_identity: Identifier, group_info: Option, - sdk: &Sdk, + _sdk: &Sdk, _sender: crate::utils::egui_mpsc::SenderAsync, ) -> Result { - let mut builder = TokenDestroyFrozenFundsTransitionBuilder::new( - data_contract.clone(), - token_position, - actor_identity.identity.id(), - frozen_identity, - ); - - if let Some(note) = public_note { - builder = builder.with_public_note(note); - } - - if let Some(group_info) = group_info { - builder = builder.with_using_group_info(group_info); - } - - if let Some(options) = self.state_transition_options() { - builder = builder.with_state_transition_creation_options(options); - } - - let state_transition = builder - .sign(sdk, &signing_key, actor_identity, self.platform_version()) - .await - .map_err(TaskError::from)?; - - // Broadcast - let proof_result = state_transition - .broadcast_and_wait::(sdk, None) + let platform_wallet = self.platform_wallet_for_identity(actor_identity)?; + let token_wallet = platform_wallet.tokens(); + + let _result = token_wallet + .destroy_frozen_funds_with_signer( + data_contract.clone(), + token_position, + actor_identity.identity.id(), + frozen_identity, + &signing_key, + actor_identity, + public_note, + group_info, + self.state_transition_options(), + ) .await .map_err(|e| self.log_drive_proof_error(e, RequestType::BroadcastStateTransition))?; // Log proof result for audit trail - tracing::info!("DestroyFrozenFunds proof result: {}", proof_result); + tracing::info!("DestroyFrozenFunds completed successfully"); // Return success with fee result use crate::backend_task::FeeResult; diff --git a/src/backend_task/tokens/freeze_tokens.rs b/src/backend_task/tokens/freeze_tokens.rs index 8c8578341..cd32384fe 100644 --- a/src/backend_task/tokens/freeze_tokens.rs +++ b/src/backend_task/tokens/freeze_tokens.rs @@ -9,7 +9,6 @@ use dash_sdk::dpp::document::DocumentV0Getters; use dash_sdk::dpp::group::GroupStateTransitionInfoStatus; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::tokens::info::v0::IdentityTokenInfoV0Accessors; -use dash_sdk::platform::tokens::builders::freeze::TokenFreezeTransitionBuilder; use dash_sdk::platform::tokens::transitions::FreezeResult; use dash_sdk::platform::{DataContract, Identifier, IdentityPublicKey}; use std::sync::Arc; @@ -25,30 +24,24 @@ impl AppContext { public_note: Option, freeze_identity: Identifier, group_info: Option, - sdk: &Sdk, + _sdk: &Sdk, _sender: crate::utils::egui_mpsc::SenderAsync, ) -> Result { - let mut builder = TokenFreezeTransitionBuilder::new( - data_contract.clone(), - token_position, - actor_identity.identity.id(), - freeze_identity, - ); + let platform_wallet = self.platform_wallet_for_identity(actor_identity)?; + let token_wallet = platform_wallet.tokens(); - if let Some(note) = public_note { - builder = builder.with_public_note(note); - } - - if let Some(group_info) = group_info { - builder = builder.with_using_group_info(group_info); - } - - if let Some(options) = self.state_transition_options() { - builder = builder.with_state_transition_creation_options(options); - } - - let result = sdk - .token_freeze(builder, &signing_key, actor_identity) + let result = token_wallet + .freeze_with_signer( + data_contract.clone(), + token_position, + actor_identity.identity.id(), + freeze_identity, + &signing_key, + actor_identity, + public_note, + group_info, + self.state_transition_options(), + ) .await .map_err(|e| self.log_drive_proof_error(e, RequestType::BroadcastStateTransition))?; diff --git a/src/backend_task/tokens/mint_tokens.rs b/src/backend_task/tokens/mint_tokens.rs index 8cdc44355..f84a08bce 100644 --- a/src/backend_task/tokens/mint_tokens.rs +++ b/src/backend_task/tokens/mint_tokens.rs @@ -11,7 +11,6 @@ use dash_sdk::dpp::group::GroupStateTransitionInfoStatus; use dash_sdk::dpp::group::group_action_status::GroupActionStatus; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::platform_value::Value; -use dash_sdk::platform::tokens::builders::mint::TokenMintTransitionBuilder; use dash_sdk::platform::tokens::transitions::MintResult; use dash_sdk::platform::{DataContract, Identifier, IdentityPublicKey}; use std::sync::Arc; @@ -28,37 +27,25 @@ impl AppContext { amount: u64, optional_recipient: Option, group_info: Option, - sdk: &Sdk, + _sdk: &Sdk, _sender: crate::utils::egui_mpsc::SenderAsync, ) -> Result { - let builder = TokenMintTransitionBuilder::new( - data_contract.clone(), - token_position, - sending_identity.identity.id(), - amount, - ); + let platform_wallet = self.platform_wallet_for_identity(sending_identity)?; + let token_wallet = platform_wallet.tokens(); - let mut builder = if let Some(recipient_id) = optional_recipient { - builder.issued_to_identity_id(recipient_id) - } else { - builder - }; - - if let Some(note) = public_note { - builder = builder.with_public_note(note); - } - - if let Some(group_info) = group_info { - builder = builder.with_using_group_info(group_info); - } - - let maybe_options = self.state_transition_options(); - if let Some(options) = maybe_options { - builder = builder.with_state_transition_creation_options(options); - } - - let result = sdk - .token_mint(builder, &signing_key, sending_identity) + let result = token_wallet + .mint_with_signer( + data_contract.clone(), + token_position, + sending_identity.identity.id(), + amount, + optional_recipient, + &signing_key, + sending_identity, + public_note, + group_info, + self.state_transition_options(), + ) .await .map_err(|e| self.log_drive_proof_error(e, RequestType::BroadcastStateTransition))?; diff --git a/src/backend_task/tokens/pause_tokens.rs b/src/backend_task/tokens/pause_tokens.rs index e923b8f2c..0f238e6c4 100644 --- a/src/backend_task/tokens/pause_tokens.rs +++ b/src/backend_task/tokens/pause_tokens.rs @@ -7,9 +7,6 @@ use crate::model::qualified_identity::QualifiedIdentity; use dash_sdk::Sdk; use dash_sdk::dpp::group::GroupStateTransitionInfoStatus; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; -use dash_sdk::dpp::state_transition::proof_result::StateTransitionProofResult; -use dash_sdk::platform::tokens::builders::emergency_action::TokenEmergencyActionTransitionBuilder; -use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; use dash_sdk::platform::{DataContract, IdentityPublicKey}; use std::sync::Arc; @@ -23,41 +20,28 @@ impl AppContext { signing_key: IdentityPublicKey, public_note: Option, group_info: Option, - sdk: &Sdk, + _sdk: &Sdk, _sender: crate::utils::egui_mpsc::SenderAsync, ) -> Result { - // Use .pause(...) constructor - let mut builder = TokenEmergencyActionTransitionBuilder::pause( - data_contract.clone(), - token_position, - actor_identity.identity.id(), - ); - - if let Some(note) = public_note { - builder = builder.with_public_note(note); - } - - if let Some(group_info) = group_info { - builder = builder.with_using_group_info(group_info); - } - - if let Some(options) = self.state_transition_options() { - builder = builder.with_state_transition_creation_options(options); - } - - let state_transition = builder - .sign(sdk, &signing_key, actor_identity, self.platform_version()) - .await - .map_err(TaskError::from)?; - - // Broadcast - let proof_result = state_transition - .broadcast_and_wait::(sdk, None) + let platform_wallet = self.platform_wallet_for_identity(actor_identity)?; + let token_wallet = platform_wallet.tokens(); + + let _result = token_wallet + .pause_with_signer( + data_contract.clone(), + token_position, + actor_identity.identity.id(), + &signing_key, + actor_identity, + public_note, + group_info, + self.state_transition_options(), + ) .await .map_err(|e| self.log_drive_proof_error(e, RequestType::BroadcastStateTransition))?; // Log proof result for audit trail - tracing::info!("PauseTokens proof result: {}", proof_result); + tracing::info!("PauseTokens completed successfully"); // Return success with fee result use crate::backend_task::FeeResult; diff --git a/src/backend_task/tokens/purchase_tokens.rs b/src/backend_task/tokens/purchase_tokens.rs index 6346f75e7..722ecd338 100644 --- a/src/backend_task/tokens/purchase_tokens.rs +++ b/src/backend_task/tokens/purchase_tokens.rs @@ -11,7 +11,6 @@ use dash_sdk::dpp::document::DocumentV0Getters; use dash_sdk::dpp::fee::Credits; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::platform_value::Value; -use dash_sdk::platform::tokens::builders::purchase::TokenDirectPurchaseTransitionBuilder; use dash_sdk::platform::tokens::transitions::DirectPurchaseResult; use dash_sdk::platform::{DataContract, Identifier, IdentityPublicKey}; use std::sync::Arc; @@ -26,23 +25,23 @@ impl AppContext { signing_key: IdentityPublicKey, amount: TokenAmount, total_agreed_price: Credits, - sdk: &Sdk, + _sdk: &Sdk, _sender: crate::utils::egui_mpsc::SenderAsync, ) -> Result { - let mut builder = TokenDirectPurchaseTransitionBuilder::new( - data_contract.clone(), - token_position, - sending_identity.identity.id(), - amount, - total_agreed_price, - ); + let platform_wallet = self.platform_wallet_for_identity(sending_identity)?; + let token_wallet = platform_wallet.tokens(); - if let Some(options) = self.state_transition_options() { - builder = builder.with_state_transition_creation_options(options); - } - - let result = sdk - .token_purchase(builder, &signing_key, sending_identity) + let result = token_wallet + .purchase_with_signer( + data_contract.clone(), + token_position, + sending_identity.identity.id(), + amount, + total_agreed_price, + &signing_key, + sending_identity, + self.state_transition_options(), + ) .await .map_err(|e| self.log_drive_proof_error(e, RequestType::BroadcastStateTransition))?; diff --git a/src/backend_task/tokens/query_my_token_balances.rs b/src/backend_task/tokens/query_my_token_balances.rs index 3473d6224..e8894607c 100644 --- a/src/backend_task/tokens/query_my_token_balances.rs +++ b/src/backend_task/tokens/query_my_token_balances.rs @@ -1,14 +1,11 @@ -//! Query token balances from Platform +//! Query token balances from Platform via TokenWallet use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::error::TaskError; use crate::context::AppContext; +use dash_sdk::Sdk; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; -use dash_sdk::platform::tokens::identity_token_balances::{ - IdentityTokenBalances, IdentityTokenBalancesQuery, -}; -use dash_sdk::platform::{FetchMany, Identifier}; -use dash_sdk::{Sdk, dpp::balances::credits::TokenAmount}; +use dash_sdk::platform::Identifier; use crate::app::TaskResult; @@ -26,6 +23,21 @@ impl AppContext { for identity in identities { let identity_id = identity.identity.id(); + + // Get the platform wallet for this identity (if available) + let platform_wallet = match self.platform_wallet_for_identity(&identity) { + Ok(pw) => pw, + Err(_) => { + // Identity may not have a wallet (e.g. single-key identity); + // fall back to direct SDK query for this identity + self.query_token_balances_direct(sdk, identity_id, &sender) + .await?; + continue; + } + }; + + let token_wallet = platform_wallet.tokens(); + let token_infos = self .identity_token_balances()? .values() @@ -33,54 +45,106 @@ impl AppContext { .map(|t| (t.token_id, t.data_contract_id, t.token_position)) .collect::>(); - let token_ids: Vec = token_infos - .iter() - .map(|(token_id, _, _)| *token_id) - .collect(); - - if token_ids.is_empty() { + if token_infos.is_empty() { continue; } - let query = IdentityTokenBalancesQuery { - identity_id, - token_ids, - }; + // Register all tokens with the TokenWallet's watch list + for (token_id, _, _) in &token_infos { + token_wallet.watch(identity_id, *token_id).await; + } - let balances_result: Result = - TokenAmount::fetch_many(sdk, query).await; - - match balances_result { - Ok(token_balances) => { - for balance in token_balances.iter() { - let token_id = balance.0; - let balance = match balance.1 { - Some(b) => *b, - None => 0, - }; - self.db.insert_identity_token_balance( - token_id, - &identity_id, - balance, - self, - )?; - sender - .send(TaskResult::Refresh) - .await - .map_err(|_| TaskError::InternalSendError)?; - } - } - Err(e) => { - return Err(TaskError::TokenQueryError { - detail: format!("Failed to query token balances: {}", e), - }); - } + // Sync balances from Platform + token_wallet + .sync() + .await + .map_err(|e| TaskError::TokenQueryError { + detail: format!("Failed to sync token balances: {}", e), + })?; + + // Read synced balances from cache and persist to local DB + for (token_id, _, _) in &token_infos { + let balance = token_wallet + .balance(&identity_id, token_id) + .await + .unwrap_or(0); + + self.db + .insert_identity_token_balance(token_id, &identity_id, balance, self)?; + sender + .send(TaskResult::Refresh) + .await + .map_err(|_| TaskError::InternalSendError)?; } } Ok(BackendTaskSuccessResult::FetchedTokenBalances) } + /// Fallback direct SDK query for identities without a platform wallet + /// (e.g. single-key identities). + async fn query_token_balances_direct( + &self, + sdk: &Sdk, + identity_id: Identifier, + sender: &crate::utils::egui_mpsc::SenderAsync, + ) -> Result<(), TaskError> { + use dash_sdk::dpp::balances::credits::TokenAmount; + use dash_sdk::platform::FetchMany; + use dash_sdk::platform::tokens::identity_token_balances::{ + IdentityTokenBalances, IdentityTokenBalancesQuery, + }; + + let token_infos = self + .identity_token_balances()? + .values() + .filter(|t| t.identity_id == identity_id) + .map(|t| (t.token_id, t.data_contract_id, t.token_position)) + .collect::>(); + + let token_ids: Vec = token_infos + .iter() + .map(|(token_id, _, _)| *token_id) + .collect(); + + if token_ids.is_empty() { + return Ok(()); + } + + let query = IdentityTokenBalancesQuery { + identity_id, + token_ids, + }; + + let balances_result: Result = + TokenAmount::fetch_many(sdk, query).await; + + match balances_result { + Ok(token_balances) => { + for balance in token_balances.iter() { + let token_id = balance.0; + let balance = match balance.1 { + Some(b) => *b, + None => 0, + }; + self.db + .insert_identity_token_balance(token_id, &identity_id, balance, self)?; + sender + .send(TaskResult::Refresh) + .await + .map_err(|_| TaskError::InternalSendError)?; + } + } + Err(e) => { + return Err(TaskError::TokenQueryError { + detail: format!("Failed to query token balances: {}", e), + }); + } + } + + Ok(()) + } + pub async fn query_token_balance( &self, sdk: &Sdk, @@ -88,6 +152,12 @@ impl AppContext { token_id: Identifier, sender: crate::utils::egui_mpsc::SenderAsync, ) -> Result { + use dash_sdk::dpp::balances::credits::TokenAmount; + use dash_sdk::platform::FetchMany; + use dash_sdk::platform::tokens::identity_token_balances::{ + IdentityTokenBalances, IdentityTokenBalancesQuery, + }; + let query = IdentityTokenBalancesQuery { identity_id, token_ids: vec![token_id], diff --git a/src/backend_task/tokens/resume_tokens.rs b/src/backend_task/tokens/resume_tokens.rs index 05903bf0a..d741f34f5 100644 --- a/src/backend_task/tokens/resume_tokens.rs +++ b/src/backend_task/tokens/resume_tokens.rs @@ -7,9 +7,6 @@ use crate::model::qualified_identity::QualifiedIdentity; use dash_sdk::Sdk; use dash_sdk::dpp::group::GroupStateTransitionInfoStatus; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; -use dash_sdk::dpp::state_transition::proof_result::StateTransitionProofResult; -use dash_sdk::platform::tokens::builders::emergency_action::TokenEmergencyActionTransitionBuilder; -use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; use dash_sdk::platform::{DataContract, IdentityPublicKey}; use std::sync::Arc; @@ -23,41 +20,28 @@ impl AppContext { signing_key: IdentityPublicKey, public_note: Option, group_info: Option, - sdk: &Sdk, + _sdk: &Sdk, _sender: crate::utils::egui_mpsc::SenderAsync, ) -> Result { - // Use .resume(...) constructor - let mut builder = TokenEmergencyActionTransitionBuilder::resume( - data_contract.clone(), - token_position, - actor_identity.identity.id(), - ); - - if let Some(note) = public_note { - builder = builder.with_public_note(note); - } - - if let Some(group_info) = group_info { - builder = builder.with_using_group_info(group_info); - } - - if let Some(options) = self.state_transition_options() { - builder = builder.with_state_transition_creation_options(options); - } - - let state_transition = builder - .sign(sdk, &signing_key, actor_identity, self.platform_version()) - .await - .map_err(TaskError::from)?; - - // Broadcast - let proof_result = state_transition - .broadcast_and_wait::(sdk, None) + let platform_wallet = self.platform_wallet_for_identity(actor_identity)?; + let token_wallet = platform_wallet.tokens(); + + let _result = token_wallet + .resume_with_signer( + data_contract.clone(), + token_position, + actor_identity.identity.id(), + &signing_key, + actor_identity, + public_note, + group_info, + self.state_transition_options(), + ) .await .map_err(|e| self.log_drive_proof_error(e, RequestType::BroadcastStateTransition))?; // Log proof result for audit trail - tracing::info!("ResumeTokens proof result: {}", proof_result); + tracing::info!("ResumeTokens completed successfully"); // Return success with fee result use crate::backend_task::FeeResult; diff --git a/src/backend_task/tokens/set_token_price.rs b/src/backend_task/tokens/set_token_price.rs index 97f9cc588..dfe0be3a6 100644 --- a/src/backend_task/tokens/set_token_price.rs +++ b/src/backend_task/tokens/set_token_price.rs @@ -9,7 +9,6 @@ use dash_sdk::dpp::document::DocumentV0Getters; use dash_sdk::dpp::group::GroupStateTransitionInfoStatus; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::tokens::token_pricing_schedule::TokenPricingSchedule; -use dash_sdk::platform::tokens::builders::set_price::TokenChangeDirectPurchasePriceTransitionBuilder; use dash_sdk::platform::tokens::transitions::SetPriceResult; use dash_sdk::platform::{DataContract, IdentityPublicKey}; use std::sync::Arc; @@ -25,33 +24,24 @@ impl AppContext { public_note: Option, token_pricing_schedule: Option, group_info: Option, - sdk: &Sdk, + _sdk: &Sdk, _sender: crate::utils::egui_mpsc::SenderAsync, ) -> Result { - let mut builder = TokenChangeDirectPurchasePriceTransitionBuilder::new( - data_contract.clone(), - token_position, - sending_identity.identity.id(), - ); + let platform_wallet = self.platform_wallet_for_identity(sending_identity)?; + let token_wallet = platform_wallet.tokens(); - if let Some(pricing_schedule) = token_pricing_schedule { - builder = builder.with_token_pricing_schedule(pricing_schedule); - } - - if let Some(note) = public_note { - builder = builder.with_public_note(note); - } - - if let Some(group_info) = group_info { - builder = builder.with_using_group_info(group_info); - } - - if let Some(options) = self.state_transition_options() { - builder = builder.with_state_transition_creation_options(options); - } - - let result = sdk - .token_set_price_for_direct_purchase(builder, &signing_key, sending_identity) + let result = token_wallet + .set_price_with_signer( + data_contract.clone(), + token_position, + sending_identity.identity.id(), + token_pricing_schedule, + &signing_key, + sending_identity, + public_note, + group_info, + self.state_transition_options(), + ) .await .map_err(|e| self.log_drive_proof_error(e, RequestType::BroadcastStateTransition))?; diff --git a/src/backend_task/tokens/transfer_tokens.rs b/src/backend_task/tokens/transfer_tokens.rs index 86c327540..cc39c8d98 100644 --- a/src/backend_task/tokens/transfer_tokens.rs +++ b/src/backend_task/tokens/transfer_tokens.rs @@ -11,7 +11,6 @@ use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; use dash_sdk::dpp::document::DocumentV0Getters; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::platform_value::Value; -use dash_sdk::platform::tokens::builders::transfer::TokenTransferTransitionBuilder; use dash_sdk::platform::tokens::transitions::TransferResult; use dash_sdk::platform::{DataContract, Identifier, IdentityPublicKey}; use std::sync::Arc; @@ -27,28 +26,24 @@ impl AppContext { token_position: u16, signing_key: IdentityPublicKey, public_note: Option, - sdk: &Sdk, + _sdk: &Sdk, _sender: crate::utils::egui_mpsc::SenderAsync, ) -> Result { - let mut builder = TokenTransferTransitionBuilder::new( - data_contract.clone(), - token_position, - sending_identity.identity.id(), - recipient_id, - amount, - ); + let platform_wallet = self.platform_wallet_for_identity(sending_identity)?; + let token_wallet = platform_wallet.tokens(); - if let Some(note) = public_note { - builder = builder.with_public_note(note); - } - - let maybe_options = self.state_transition_options(); - if let Some(options) = maybe_options { - builder = builder.with_state_transition_creation_options(options); - } - - let result = sdk - .token_transfer(builder, &signing_key, sending_identity) + let result = token_wallet + .transfer_with_signer( + data_contract.clone(), + token_position, + sending_identity.identity.id(), + recipient_id, + amount, + &signing_key, + sending_identity, + public_note, + self.state_transition_options(), + ) .await .map_err(|e| self.log_drive_proof_error(e, RequestType::BroadcastStateTransition))?; diff --git a/src/backend_task/tokens/unfreeze_tokens.rs b/src/backend_task/tokens/unfreeze_tokens.rs index 36b813943..413d20074 100644 --- a/src/backend_task/tokens/unfreeze_tokens.rs +++ b/src/backend_task/tokens/unfreeze_tokens.rs @@ -9,7 +9,6 @@ use dash_sdk::dpp::document::DocumentV0Getters; use dash_sdk::dpp::group::GroupStateTransitionInfoStatus; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::tokens::info::v0::IdentityTokenInfoV0Accessors; -use dash_sdk::platform::tokens::builders::unfreeze::TokenUnfreezeTransitionBuilder; use dash_sdk::platform::tokens::transitions::UnfreezeResult; use dash_sdk::platform::{DataContract, Identifier, IdentityPublicKey}; use std::sync::Arc; @@ -25,30 +24,24 @@ impl AppContext { public_note: Option, unfreeze_identity: Identifier, group_info: Option, - sdk: &Sdk, + _sdk: &Sdk, _sender: crate::utils::egui_mpsc::SenderAsync, ) -> Result { - let mut builder = TokenUnfreezeTransitionBuilder::new( - data_contract.clone(), - token_position, - actor_identity.identity.id(), - unfreeze_identity, - ); + let platform_wallet = self.platform_wallet_for_identity(actor_identity)?; + let token_wallet = platform_wallet.tokens(); - if let Some(note) = public_note { - builder = builder.with_public_note(note); - } - - if let Some(group_info) = group_info { - builder = builder.with_using_group_info(group_info); - } - - if let Some(options) = self.state_transition_options() { - builder = builder.with_state_transition_creation_options(options); - } - - let result = sdk - .token_unfreeze_identity(builder, &signing_key, actor_identity) + let result = token_wallet + .unfreeze_with_signer( + data_contract.clone(), + token_position, + actor_identity.identity.id(), + unfreeze_identity, + &signing_key, + actor_identity, + public_note, + group_info, + self.state_transition_options(), + ) .await .map_err(|e| self.log_drive_proof_error(e, RequestType::BroadcastStateTransition))?; diff --git a/src/backend_task/tokens/update_token_config.rs b/src/backend_task/tokens/update_token_config.rs index 1d25964d1..ea31fe122 100644 --- a/src/backend_task/tokens/update_token_config.rs +++ b/src/backend_task/tokens/update_token_config.rs @@ -7,9 +7,6 @@ use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; use dash_sdk::dpp::group::GroupStateTransitionInfoStatus; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; -use dash_sdk::dpp::state_transition::proof_result::StateTransitionProofResult; -use dash_sdk::platform::tokens::builders::config_update::TokenConfigUpdateTransitionBuilder; -use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; use dash_sdk::platform::{DataContract, Fetch, IdentityPublicKey}; use dash_sdk::{ Sdk, @@ -47,41 +44,26 @@ impl AppContext { let data_contract_arc = Arc::new(existing_data_contract.clone()); - // Create the TokenConfigUpdateTransition - let mut builder = TokenConfigUpdateTransitionBuilder::new( - data_contract_arc, - identity_token_info.token_position, - identity_token_info.identity.identity.id(), - change_item.clone(), - ); - - // Add the optional public note - if let Some(public_note) = &public_note { - builder = builder.with_public_note(public_note.clone()); - } - - if let Some(group_info) = group_info { - builder = builder.with_using_group_info(group_info); - } - - if let Some(options) = self.state_transition_options() { - builder = builder.with_state_transition_creation_options(options); - } - - // Sign the state transition - let state_transition = builder - .sign(sdk, signing_key, &identity, self.platform_version()) - .await - .map_err(TaskError::from)?; + let platform_wallet = self.platform_wallet_for_identity(&identity)?; + let token_wallet = platform_wallet.tokens(); - // Broadcast the state transition - let proof_result = state_transition - .broadcast_and_wait::(sdk, None) + let _result = token_wallet + .update_config_with_signer( + data_contract_arc, + identity_token_info.token_position, + identity_token_info.identity.identity.id(), + change_item.clone(), + signing_key, + &identity, + public_note, + group_info, + self.state_transition_options(), + ) .await .map_err(|e| self.log_drive_proof_error(e, RequestType::BroadcastStateTransition))?; // Log proof result for audit trail - tracing::info!("TokenConfigUpdate proof result: {}", proof_result); + tracing::info!("TokenConfigUpdate completed successfully"); // Now update the data contract in the local database // The proof result contains an action document, not the updated contract, diff --git a/src/backend_task/wallet/fetch_platform_address_balances.rs b/src/backend_task/wallet/fetch_platform_address_balances.rs index 359d73802..df19ff14b 100644 --- a/src/backend_task/wallet/fetch_platform_address_balances.rs +++ b/src/backend_task/wallet/fetch_platform_address_balances.rs @@ -19,6 +19,12 @@ impl AppContext { tracing::info!("Platform address sync start"); let start_time = std::time::Instant::now(); + // Validate via platform wallet bridge (establishes the new lookup path). + // The platform wallet is not yet used for address derivation — the old + // Wallet model's WalletAddressProvider handles that. This will be migrated + // once PlatformAddressWallet provides equivalent functionality. + let _platform_wallet = self.get_platform_wallet(&seed_hash); + let wallet_arc = { let wallets = self.wallets.read()?; wallets @@ -31,11 +37,19 @@ impl AppContext { let (last_sync_timestamp, last_sync_height) = self.db.get_platform_sync_info(&seed_hash).unwrap_or((0, 0)); + // Load stored platform address info from DB for incremental sync + let stored_info = self + .db + .get_all_platform_address_info(&seed_hash, &self.network) + .unwrap_or_default(); + // Create provider (requires wallet to be open for address derivation) let mut provider = { let wallet = wallet_arc.read()?; match WalletAddressProvider::new(&wallet, self.network) { - Ok(provider) => provider.with_stored_state(&wallet, self.network, last_sync_height), + Ok(provider) => { + provider.with_stored_state(&stored_info, self.network, last_sync_height) + } Err(_) if !wallet.is_open() => { return Err(crate::backend_task::error::TaskError::WalletLocked); } @@ -120,58 +134,52 @@ impl AppContext { tracing::warn!("Failed to save platform sync info: {}", e); } - // Apply results to wallet and persist - let balances = { - let mut wallet = wallet_arc.write()?; - - // Update wallet with synced balances - provider.apply_results_to_wallet(&mut wallet); - - // Persist addresses and balances to database - for (index, (address, funds)) in provider.found_balances_with_indices() { - // Persist the address to wallet_addresses table if not already there - let derivation_path = DerivationPath::platform_payment_path( - self.network, - 0, // account - 0, // key_class - index, - ); - if let Err(e) = self.db.add_address_if_not_exists( - &seed_hash, - address, - &self.network, - &derivation_path, - DerivationPathReference::PlatformPayment, - DerivationPathType::CLEAR_FUNDS, - None, - ) { - tracing::warn!("Failed to persist Platform address: {}", e); - } + // Persist addresses and balances to database + for (index, (address, funds)) in provider.found_balances_with_indices() { + // Persist the address to wallet_addresses table if not already there + let derivation_path = DerivationPath::platform_payment_path( + self.network, + 0, // account + 0, // key_class + index, + ); + if let Err(e) = self.db.add_address_if_not_exists( + &seed_hash, + address, + &self.network, + &derivation_path, + DerivationPathReference::PlatformPayment, + DerivationPathType::CLEAR_FUNDS, + None, + ) { + tracing::warn!("Failed to persist Platform address: {}", e); + } - // Persist balance to platform_address_balances table - if let Err(e) = self.db.set_platform_address_info( - &seed_hash, - address, - funds.balance, - funds.nonce, - &self.network, - ) { - tracing::warn!("Failed to persist Platform address info: {}", e); - } + // Persist balance to platform_address_balances table + if let Err(e) = self.db.set_platform_address_info( + &seed_hash, + address, + funds.balance, + funds.nonce, + &self.network, + ) { + tracing::warn!("Failed to persist Platform address info: {}", e); } + } - // 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, info)| (addr.clone(), (info.balance, info.nonce))) - .collect() - }; + // Return the complete platform_address_info from DB, 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 DB. + // Returning only found_balances would cause the UI to lose nonce + // values for stable-balance addresses (issue #652). + let balances = self + .db + .get_all_platform_address_info(&seed_hash, &self.network) + .unwrap_or_default() + .into_iter() + .map(|(addr, balance, nonce)| (addr, (balance, nonce))) + .collect(); let addresses_with_balance = provider.found_balances().len(); tracing::info!( 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 a06d7b74a..1a1156136 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 @@ -19,18 +19,22 @@ impl AppContext { asset_lock_address: Address, outputs: BTreeMap>, ) -> Result { + use crate::backend_task::error::TaskError; use dash_sdk::dpp::address_funds::AddressFundsFeeStrategyStep; use dash_sdk::dpp::dashcore::OutPoint; use dash_sdk::platform::transition::top_up_address::TopUpAddress; - // Clone wallet and SDK before the async operation to avoid holding guards across await - let (wallet, sdk, asset_lock_private_key) = { + // Get the platform wallet for signing (PlatformAddressWallet implements Signer) + let platform_wallet = self.require_platform_wallet(&seed_hash)?; + + // Clone wallet (for asset lock private key lookup) and SDK before the async operation + let (sdk, asset_lock_private_key) = { let wallet_arc = { let wallets = self.wallets.read()?; wallets .get(&seed_hash) .cloned() - .ok_or(crate::backend_task::error::TaskError::WalletNotFound)? + .ok_or(TaskError::WalletNotFound)? }; let wallet = wallet_arc.read()?.clone(); let sdk = self.sdk.load().as_ref().clone(); @@ -43,7 +47,7 @@ impl AppContext { )? .ok_or(crate::backend_task::error::TaskError::AssetLockAddressNotFound)?; - (wallet, sdk, private_key) + (sdk, private_key) }; // Check if we need to convert an old instant lock proof to a chain lock proof @@ -107,27 +111,14 @@ impl AppContext { asset_lock_proof, asset_lock_private_key, fee_strategy, - &wallet, + platform_wallet.platform(), None, ) .await .map_err(crate::backend_task::error::TaskError::from)?; - // Remove the used asset lock from the wallet and database + // Remove the used asset lock from the database { - let wallet_arc = { - self.wallets - .read() - .ok() - .and_then(|w| w.get(&seed_hash).cloned()) - }; - if let Some(wallet_arc) = wallet_arc { - let mut wallet = wallet_arc.write()?; - wallet - .unused_asset_locks - .retain(|(tx, _, _, _, _)| tx.txid() != tx_id); - } - // Also remove from database if let Err(e) = self .db .delete_asset_lock_transaction(&tx_id.to_byte_array()) 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 e786620e1..b20c4bec9 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 @@ -24,7 +24,7 @@ impl AppContext { // When fee_deduct_from_output is false, we need to create a larger asset lock // that includes the estimated platform fee, so the recipient receives the exact amount. - let (asset_lock_amount, allow_take_fee_from_amount) = if fee_deduct_from_output { + let (asset_lock_amount, _allow_take_fee_from_amount) = if fee_deduct_from_output { // Fees deducted from output: use the requested amount, allow core fee to be taken from it (amount, true) } else { @@ -38,115 +38,26 @@ impl AppContext { (asset_lock_amount, false) }; - // Step 1: Create the asset lock transaction (UTXOs are selected but NOT yet removed) - let (asset_lock_transaction, asset_lock_private_key, _asset_lock_address, used_utxos) = { - let wallet_arc = { - let wallets = self.wallets.read()?; - wallets - .get(&seed_hash) - .cloned() - .ok_or(TaskError::WalletNotFound)? - }; - - let mut wallet = wallet_arc.write()?; + // Build, broadcast, and wait for finality proof in a single call. + // AssetLockManager handles the full lifecycle: UTXO selection, TX + // construction, broadcast, and IS-lock / ChainLock proof wait. + let platform_wallet = self.require_platform_wallet(&seed_hash)?; - // Try to create the asset lock transaction, reload UTXOs if needed - match wallet.generic_asset_lock_transaction( - self, - self.network, + let (asset_lock_proof, asset_lock_private_key, _out_point) = platform_wallet + .asset_locks() + .create_funded_asset_lock_proof( asset_lock_amount, - allow_take_fee_from_amount, - None, - ) { - Ok((tx, private_key, address, _change, utxos)) => (tx, private_key, address, utxos), - Err(e) => { - // Reload UTXOs (RPC: fetches from Core; SPV: no-op). - // Only retry if something actually changed. - if !wallet - .reload_utxos(self) - .map_err(|e| TaskError::UtxoUpdateFailed { detail: e })? - { - return Err(TaskError::AssetLockTransactionBuildFailed { detail: e }); - } - let (tx, private_key, address, _change, utxos) = wallet - .generic_asset_lock_transaction( - self, - self.network, - asset_lock_amount, - allow_take_fee_from_amount, - None, - ) - .map_err(|e| TaskError::AssetLockTransactionBuildFailed { detail: e })?; - (tx, private_key, address, utxos) - } - } - }; - - // Step 2–4: Store → broadcast → remove UTXOs (atomic pattern). - let wallet_arc = { - let wallets = self.wallets.read()?; - wallets - .get(&seed_hash) - .cloned() - .ok_or(TaskError::WalletNotFound)? - }; - - let tx_id = self - .broadcast_and_commit_asset_lock( - &asset_lock_transaction, - asset_lock_amount, - &seed_hash, - &wallet_arc, - &used_utxos, + 0, + platform_wallet::AssetLockFundingType::IdentityRegistration, + 0, ) - .await?; - - // Step 5: Wait for asset lock proof (InstantLock or ChainLock) via shared helper. - // On timeout the helper cleans up the finality tracking entry. - // Post-timeout recovery is mode-dependent: - // RPC — fire-and-forget refresh_wallet_info to reconcile spent UTXOs - // SPV — spent UTXOs are reconciled automatically on the next sync cycle - let asset_lock_proof = match self.wait_for_asset_lock_proof(tx_id).await { - Ok(proof) => proof, - Err(timeout_err) => { - use crate::spv::CoreBackendMode; - - match self.core_backend_mode() { - CoreBackendMode::Rpc => { - if let Some(wallet_arc) = self - .wallets - .read() - .ok() - .and_then(|w| w.get(&seed_hash).cloned()) - { - let ctx = Arc::clone(self); - // Fire-and-forget — don't block the error return on refresh - 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 - ); - } - }); - } - } - CoreBackendMode::Spv => { - tracing::warn!( - "Asset lock proof timed out in SPV mode (tx {}). \ - Spent UTXOs will be reconciled automatically during \ - the next SPV sync cycle when a new block arrives.", - tx_id - ); - } - } - - return Err(timeout_err); - } - }; + .await + .map_err(|e| TaskError::AssetLockTransactionBuildFailed { + detail: e.to_string(), + })?; - // Step 6: Get wallet, SDK, and derive a fresh change address if needed - let (wallet, sdk, change_platform_address) = { + // Step 6: Get platform wallet, SDK, and derive a fresh change address if needed + let (platform_wallet, sdk, change_platform_address) = { let wallet_arc = { let wallets = self.wallets.read()?; wallets @@ -173,9 +84,15 @@ impl AppContext { None }; - let wallet = wallet_arc.read()?.clone(); + let wallet_guard = wallet_arc.read()?; + let platform_wallet = wallet_guard + .platform_wallet + .as_ref() + .cloned() + .ok_or(crate::backend_task::error::TaskError::WalletLocked)?; + drop(wallet_guard); let sdk = self.sdk.load().as_ref().clone(); - (wallet, sdk, change_platform_address) + (platform_wallet, sdk, change_platform_address) }; // Step 7: Fund the destination platform address @@ -224,7 +141,7 @@ impl AppContext { asset_lock_proof, asset_lock_private_key, fee_strategy, - &wallet, + platform_wallet.platform(), None, ) .await diff --git a/src/backend_task/wallet/generate_receive_address.rs b/src/backend_task/wallet/generate_receive_address.rs index 977cbb8ac..69cbbb40d 100644 --- a/src/backend_task/wallet/generate_receive_address.rs +++ b/src/backend_task/wallet/generate_receive_address.rs @@ -1,4 +1,5 @@ use crate::backend_task::BackendTaskSuccessResult; +use crate::backend_task::error::TaskError; use crate::context::AppContext; use crate::model::wallet::{DerivationPathReference, DerivationPathType, WalletSeedHash}; use crate::spv::CoreBackendMode; @@ -8,44 +9,47 @@ impl AppContext { pub(crate) async fn generate_receive_address( self: &Arc, seed_hash: WalletSeedHash, - ) -> Result { + ) -> Result { let wallet_arc = { let wallets = self.wallets.read()?; wallets .get(&seed_hash) .cloned() - .ok_or(crate::backend_task::error::TaskError::WalletNotFound)? + .ok_or(TaskError::WalletNotFound)? }; let address_string = if self.core_backend_mode() == CoreBackendMode::Spv { - let derived = self - .spv_manager - .next_bip44_receive_address(seed_hash, 0) - .await - .map_err(|e| { - crate::backend_task::error::TaskError::WalletAddressDerivationFailed { - detail: e, - } - })?; + // Use PlatformWallet's CoreWallet for address derivation in SPV mode. + let platform_wallet = self.require_platform_wallet(&seed_hash)?; + let address = platform_wallet.core().next_receive_address().await.map_err(|e| { + TaskError::WalletAddressDerivationFailed { + detail: e.to_string(), + } + })?; - let _ = self.register_spv_address( - &wallet_arc, - derived.address.clone(), - derived.derivation_path.clone(), - DerivationPathType::CLEAR_FUNDS, - DerivationPathReference::BIP44, - )?; + // Register the address in DET's address table so it shows in the UI. + // Read derivation path from the ManagedWalletInfo accounts. + if let Some(wallet_info) = platform_wallet.try_state() { + for acc in wallet_info.managed_state.wallet_info().accounts.all_accounts() { + if let Some(ai) = acc.get_address_info(&address) { + let _ = self.register_spv_address( + &wallet_arc, + address.clone(), + ai.path.clone(), + DerivationPathType::CLEAR_FUNDS, + DerivationPathReference::BIP44, + ); + break; + } + } + } - derived.address.to_string() + address.to_string() } else { let mut wallet = wallet_arc.write()?; wallet .receive_address(self.network, true, Some(self)) - .map_err(|e| { - crate::backend_task::error::TaskError::WalletAddressDerivationFailed { - detail: e, - } - })? + .map_err(|e| TaskError::WalletAddressDerivationFailed { detail: e })? .to_string() }; diff --git a/src/backend_task/wallet/mod.rs b/src/backend_task/wallet/mod.rs index 83a227fd7..11a8e6e14 100644 --- a/src/backend_task/wallet/mod.rs +++ b/src/backend_task/wallet/mod.rs @@ -67,4 +67,8 @@ pub enum WalletTask { /// If false, fees are paid from extra wallet balance (recipient receives exact amount). fee_deduct_from_output: bool, }, + /// Load per-address info from the CoreWallet via the platform-wallet bridge + LoadAddressInfo { + seed_hash: WalletSeedHash, + }, } diff --git a/src/backend_task/wallet/transfer_platform_credits.rs b/src/backend_task/wallet/transfer_platform_credits.rs index 941db65de..ef6bc212d 100644 --- a/src/backend_task/wallet/transfer_platform_credits.rs +++ b/src/backend_task/wallet/transfer_platform_credits.rs @@ -18,19 +18,9 @@ impl AppContext { use dash_sdk::dpp::address_funds::AddressFundsFeeStrategyStep; use dash_sdk::platform::transition::transfer_address_funds::TransferAddressFunds; - // Clone wallet and SDK before the async operation to avoid holding guards across await - let (wallet, sdk) = { - let wallet_arc = { - let wallets = self.wallets.read()?; - wallets - .get(&seed_hash) - .cloned() - .ok_or(crate::backend_task::error::TaskError::WalletNotFound)? - }; - let wallet = wallet_arc.read()?.clone(); - let sdk = self.sdk.load().as_ref().clone(); - (wallet, sdk) - }; + // Get the platform wallet for signing (PlatformAddressWallet implements Signer) + let platform_wallet = self.require_platform_wallet(&seed_hash)?; + let sdk = self.sdk.load().as_ref().clone(); // Deduct fee from the specified input address (should be the one with highest balance). let fee_strategy = vec![AddressFundsFeeStrategyStep::DeductFromInput( @@ -49,7 +39,13 @@ impl AppContext { // Use the SDK to transfer - returns proof-verified updated address infos let address_infos = sdk - .transfer_address_funds(inputs, outputs, fee_strategy, &wallet, None) + .transfer_address_funds( + inputs, + outputs, + fee_strategy, + platform_wallet.platform(), + None, + ) .await .map_err(crate::backend_task::error::TaskError::from)?; diff --git a/src/backend_task/wallet/withdraw_from_platform_address.rs b/src/backend_task/wallet/withdraw_from_platform_address.rs index 38dcd89b3..a2d8117e5 100644 --- a/src/backend_task/wallet/withdraw_from_platform_address.rs +++ b/src/backend_task/wallet/withdraw_from_platform_address.rs @@ -21,19 +21,9 @@ impl AppContext { use dash_sdk::dpp::withdrawal::Pooling; use dash_sdk::platform::transition::address_credit_withdrawal::WithdrawAddressFunds; - // Clone wallet and SDK before the async operation to avoid holding guards across await - let (wallet, sdk) = { - let wallet_arc = { - let wallets = self.wallets.read()?; - wallets - .get(&seed_hash) - .cloned() - .ok_or(crate::backend_task::error::TaskError::WalletNotFound)? - }; - let wallet = wallet_arc.read()?.clone(); - let sdk = self.sdk.load().as_ref().clone(); - (wallet, sdk) - }; + // Get the platform wallet for signing (PlatformAddressWallet implements Signer) + let platform_wallet = self.require_platform_wallet(&seed_hash)?; + let sdk = self.sdk.load().as_ref().clone(); // Deduct fee from the specified input (should be the one with highest balance) let fee_strategy = vec![AddressFundsFeeStrategyStep::DeductFromInput( @@ -49,7 +39,7 @@ impl AppContext { core_fee_per_byte, Pooling::Never, output_script, - &wallet, + platform_wallet.platform(), None, ) .await diff --git a/src/bin/det_cli/connect.rs b/src/bin/det_cli/connect.rs index d834d0aea..e7ef38314 100644 --- a/src/bin/det_cli/connect.rs +++ b/src/bin/det_cli/connect.rs @@ -64,11 +64,9 @@ pub(super) async fn connect_http( StreamableHttpClientTransport, StreamableHttpClientTransportConfig, }; - let config = StreamableHttpClientTransportConfig { - uri: addr.into(), - auth_header: bearer.map(|token| format!("Bearer {token}")), - ..Default::default() - }; + let mut config = StreamableHttpClientTransportConfig::default(); + config.uri = addr.into(); + config.auth_header = bearer.map(|token| format!("Bearer {token}")); let transport = StreamableHttpClientTransport::from_config(config); let client = ().serve(transport).await?; Ok(client) diff --git a/src/changeset/mod.rs b/src/changeset/mod.rs new file mode 100644 index 000000000..5dbe5c85c --- /dev/null +++ b/src/changeset/mod.rs @@ -0,0 +1,3 @@ +pub mod sqlite; +pub use sqlite::FlushStrategy; +pub use sqlite::SqliteWalletPersister; diff --git a/src/changeset/sqlite.rs b/src/changeset/sqlite.rs new file mode 100644 index 000000000..2274a9ca9 --- /dev/null +++ b/src/changeset/sqlite.rs @@ -0,0 +1,1281 @@ +//! SQLite-backed implementation of [`PlatformWalletPersistence`]. +//! +//! Persists wallet change deltas into the existing evo-tool database tables +//! (`wallet`, `wallet_transactions`, `utxos`) using a single `rusqlite::Transaction` +//! for atomicity. + +use crate::database::Database; +use dash_sdk::dpp::dashcore::consensus::{deserialize, serialize}; +use dash_sdk::dpp::dashcore::hashes::Hash; +use dash_sdk::dpp::dashcore::{BlockHash, OutPoint, Transaction, Txid}; +use dash_sdk::dpp::key_wallet::PlatformP2PKHAddress; +use dash_sdk::dpp::key_wallet::dip9::DerivationPathReference; +use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; +use dash_sdk::dpp::prelude::{AssetLockProof, Identifier}; +use dash_sdk::dpp::serialization::{PlatformDeserializable, PlatformSerializable}; +use platform_wallet::AssetLockStatus; +use platform_wallet::changeset::Merge; +use platform_wallet::changeset::PlatformWalletPersistence; +use platform_wallet::changeset::changeset::{ + AccountChangeSet, AssetLockChangeSet, AssetLockEntry, ChainChangeSet, IdentityChangeSet, + IdentityEntry, PlatformAddressChangeSet, PlatformAddressEntry, PlatformWalletChangeSet, + TransactionChangeSet, TransactionEntry, UtxoChangeSet, +}; +use platform_wallet::wallet::WalletId; +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::{Arc, Mutex}; + +/// Controls when queued changesets are written to storage. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FlushStrategy { + /// Flush to storage after every [`queue`](PlatformWalletPersistence::queue) call. + Immediate, + /// Caller must explicitly call [`flush`](PlatformWalletPersistence::flush). + Manual, +} + +/// Persists [`PlatformWalletChangeSet`] deltas into the evo-tool SQLite database. +/// +/// Changesets are buffered via [`queue`](PlatformWalletPersistence::queue) and +/// written atomically on [`flush`](PlatformWalletPersistence::flush). +/// +/// When [`FlushStrategy::Immediate`] is set (the default), each `queue()` call +/// automatically triggers a `flush()`, so callers don't need to call +/// `persist_platform_wallet` / `flush_persist` separately. +/// +/// A single persister instance is shared across all wallets managed by a +/// [`PlatformWalletManager`]. Each wallet is identified by its `WalletId` +/// (which equals the evo-tool `seed_hash`). +pub struct SqliteWalletPersister { + db: Arc, + network: String, + /// Per-wallet accumulated changesets waiting to be flushed. + staged: Mutex>, + /// When to write queued changesets to storage. + strategy: FlushStrategy, +} + +/// Error type for [`SqliteWalletPersister`]. +#[derive(Debug, thiserror::Error)] +pub enum SqlitePersistError { + #[error("SQLite error: {0}")] + Sqlite(#[from] rusqlite::Error), +} + +impl SqliteWalletPersister { + /// Create a new persister for the given `network`. + /// + /// Uses [`FlushStrategy::Immediate`] by default so that every `queue()` call + /// is automatically persisted. The persister is wallet-id-aware: the + /// `wallet_id` is passed per-call to [`queue`], [`flush`] and + /// [`initialize`]. + pub fn new(db: Arc, network: String) -> Self { + Self { + db, + network, + staged: Mutex::new(BTreeMap::new()), + strategy: FlushStrategy::Immediate, + } + } + + /// Set the flush strategy for this persister. + #[allow(dead_code)] + pub fn with_strategy(mut self, strategy: FlushStrategy) -> Self { + self.strategy = strategy; + self + } +} + +/// Convert a `u32` discriminant back to [`DerivationPathReference`]. +/// +/// The key-wallet crate does not expose a `TryFrom` impl for its enum, +/// so we maintain a local mapping that mirrors the discriminant values. +fn derivation_path_reference_from_u32(v: u32) -> Option { + match v { + 0 => Some(DerivationPathReference::Unknown), + 1 => Some(DerivationPathReference::BIP32), + 2 => Some(DerivationPathReference::BIP44), + 3 => Some(DerivationPathReference::BlockchainIdentities), + 4 => Some(DerivationPathReference::ProviderFunds), + 5 => Some(DerivationPathReference::ProviderVotingKeys), + 6 => Some(DerivationPathReference::ProviderOperatorKeys), + 7 => Some(DerivationPathReference::ProviderOwnerKeys), + 8 => Some(DerivationPathReference::ContactBasedFunds), + 9 => Some(DerivationPathReference::ContactBasedFundsRoot), + 10 => Some(DerivationPathReference::ContactBasedFundsExternal), + 11 => Some(DerivationPathReference::BlockchainIdentityCreditRegistrationFunding), + 12 => Some(DerivationPathReference::BlockchainIdentityCreditTopupFunding), + 13 => Some(DerivationPathReference::BlockchainIdentityCreditInvitationFunding), + 14 => Some(DerivationPathReference::ProviderPlatformNodeKeys), + 15 => Some(DerivationPathReference::CoinJoin), + 16 => Some(DerivationPathReference::PlatformPayment), + 17 => Some(DerivationPathReference::BlockchainAssetLockAddressTopupFunding), + 18 => Some(DerivationPathReference::BlockchainAssetLockShieldedAddressTopupFunding), + 255 => Some(DerivationPathReference::Root), + _ => None, + } +} + +/// Convert an [`AssetLockFundingType`] to an integer discriminant for SQLite storage. +fn funding_type_to_i64(ft: AssetLockFundingType) -> i64 { + match ft { + AssetLockFundingType::IdentityRegistration => 0, + AssetLockFundingType::IdentityTopUp => 1, + AssetLockFundingType::IdentityTopUpNotBound => 2, + AssetLockFundingType::IdentityInvitation => 3, + AssetLockFundingType::AssetLockAddressTopUp => 4, + AssetLockFundingType::AssetLockShieldedAddressTopUp => 5, + } +} + +/// Convert an integer discriminant back to [`AssetLockFundingType`]. +fn funding_type_from_i64(v: i64) -> Option { + match v { + 0 => Some(AssetLockFundingType::IdentityRegistration), + 1 => Some(AssetLockFundingType::IdentityTopUp), + 2 => Some(AssetLockFundingType::IdentityTopUpNotBound), + 3 => Some(AssetLockFundingType::IdentityInvitation), + 4 => Some(AssetLockFundingType::AssetLockAddressTopUp), + 5 => Some(AssetLockFundingType::AssetLockShieldedAddressTopUp), + _ => None, + } +} + +impl SqliteWalletPersister { + /// Persist platform-level transaction changeset entries into `wallet_transactions`. + fn persist_transactions( + tx: &rusqlite::Transaction, + seed_hash: &[u8; 32], + network: &str, + txs: &TransactionChangeSet, + ) -> Result<(), rusqlite::Error> { + let mut stmt = tx.prepare_cached( + "INSERT OR REPLACE INTO wallet_transactions ( + seed_hash, txid, network, timestamp, height, block_hash, + net_amount, fee, label, is_ours, raw_transaction, status + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + )?; + + for (txid, entry) in &txs.transactions { + let raw = serialize(&entry.transaction); + let block_hash_bytes: Option> = + entry.block_hash.map(|bh| bh.as_byte_array().to_vec()); + + // Derive a simple status integer compatible with the existing schema: + // 0 = unconfirmed, 1 = instant-locked, 2 = confirmed/chain-locked. + let status: i32 = if entry.is_chain_locked { + 2 + } else if entry.is_instant_locked { + 1 + } else { + 0 + }; + + stmt.execute(rusqlite::params![ + &seed_hash[..], + txid.as_byte_array(), + network, + entry.timestamp as i64, + entry.block_height.map(|h| h as i64), + block_hash_bytes, + entry.net_amount, + entry.fee.map(|f| f as i64), + &entry.label, + 1i32, // is_ours: changeset transactions are always ours + &raw, + status, + ])?; + } + Ok(()) + } + + /// Persist platform-level UTXO changeset entries into `utxos`. + fn persist_utxos( + tx: &rusqlite::Transaction, + network: &str, + utxos: &UtxoChangeSet, + ) -> Result<(), rusqlite::Error> { + // Insert added UTXOs. + // The platform-level UtxoChangeSet only carries outpoint -> value (no + // address/script). We store a placeholder; full details come from SPV. + let mut insert_stmt = tx.prepare_cached( + "INSERT OR IGNORE INTO utxos (txid, vout, address, value, script_pubkey, network) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + )?; + for (outpoint, value) in &utxos.added { + insert_stmt.execute(rusqlite::params![ + outpoint.txid.as_byte_array(), + outpoint.vout as i64, + "", // address placeholder + *value as i64, + &[] as &[u8], // script_pubkey placeholder + network, + ])?; + } + + // Delete spent UTXOs. + let mut delete_stmt = + tx.prepare_cached("DELETE FROM utxos WHERE txid = ?1 AND vout = ?2 AND network = ?3")?; + for outpoint in &utxos.spent { + delete_stmt.execute(rusqlite::params![ + outpoint.txid.as_byte_array(), + outpoint.vout as i64, + network, + ])?; + } + Ok(()) + } + + /// Ensure the `wallet_account_state` table exists. + fn ensure_account_state_table(tx: &rusqlite::Transaction) -> Result<(), rusqlite::Error> { + tx.execute_batch( + "CREATE TABLE IF NOT EXISTS wallet_account_state ( + seed_hash BLOB NOT NULL, + account_index INTEGER NOT NULL, + path_reference INTEGER NOT NULL, + last_revealed INTEGER NOT NULL, + network TEXT NOT NULL, + PRIMARY KEY (seed_hash, account_index, path_reference, network) + )", + )?; + Ok(()) + } + + /// Persist platform-wallet account changeset (last revealed indices) into `wallet_account_state`. + fn persist_accounts( + tx: &rusqlite::Transaction, + seed_hash: &[u8; 32], + network: &str, + accounts: &AccountChangeSet, + ) -> Result<(), rusqlite::Error> { + Self::ensure_account_state_table(tx)?; + + let mut stmt = tx.prepare_cached( + "INSERT OR REPLACE INTO wallet_account_state + (seed_hash, account_index, path_reference, last_revealed, network) + VALUES (?1, ?2, ?3, ?4, ?5)", + )?; + + for (&(account_index, path_ref), &last_revealed) in &accounts.last_revealed { + stmt.execute(rusqlite::params![ + &seed_hash[..], + account_index as i64, + path_ref as u32 as i64, + last_revealed as i64, + network, + ])?; + } + Ok(()) + } + + /// Persist key-wallet account changeset (last revealed indices without path reference). + /// + /// The key-wallet [`key_wallet::changeset::AccountChangeSet`] maps + /// `account_index -> last_revealed` without a `DerivationPathReference` dimension. + /// We store these with `path_reference = 0` (Unknown) as a sentinel. + fn persist_key_wallet_accounts( + tx: &rusqlite::Transaction, + seed_hash: &[u8; 32], + network: &str, + accounts: &dash_sdk::dpp::key_wallet::changeset::AccountChangeSet, + ) -> Result<(), rusqlite::Error> { + Self::ensure_account_state_table(tx)?; + + let mut stmt = tx.prepare_cached( + "INSERT OR REPLACE INTO wallet_account_state + (seed_hash, account_index, path_reference, last_revealed, network) + VALUES (?1, ?2, ?3, ?4, ?5)", + )?; + + for (&account_index, &last_revealed) in &accounts.last_revealed { + stmt.execute(rusqlite::params![ + &seed_hash[..], + account_index as i64, + 0i64, // Unknown path reference — key-wallet does not carry one + last_revealed as i64, + network, + ])?; + } + Ok(()) + } + + /// Persist identity changeset into the existing `identity` and `top_up` tables, + /// and a `wallet_identity_dpns_names` table for DPNS names. + fn persist_identities( + tx: &rusqlite::Transaction, + seed_hash: &[u8; 32], + network: &str, + identities: &IdentityChangeSet, + ) -> Result<(), rusqlite::Error> { + // Ensure the DPNS names table exists. + tx.execute_batch( + "CREATE TABLE IF NOT EXISTS wallet_identity_dpns_names ( + identity_id BLOB NOT NULL, + name TEXT NOT NULL, + network TEXT NOT NULL, + PRIMARY KEY (identity_id, name, network) + )", + )?; + + let mut identity_stmt = tx.prepare_cached( + "INSERT OR REPLACE INTO identity + (id, data, is_local, alias, wallet, wallet_index, network, status) + VALUES (?1, ?2, 1, ?3, ?4, ?5, ?6, 0)", + )?; + + let mut topup_stmt = tx.prepare_cached( + "INSERT OR REPLACE INTO top_up (identity_id, top_up_index, amount) + VALUES (?1, ?2, ?3)", + )?; + + let mut dpns_stmt = tx.prepare_cached( + "INSERT OR IGNORE INTO wallet_identity_dpns_names + (identity_id, name, network) + VALUES (?1, ?2, ?3)", + )?; + + for (id, entry) in &identities.identities { + let identity_bytes = entry + .identity + .serialize_to_bytes() + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; + + identity_stmt.execute(rusqlite::params![ + id.as_bytes(), + &identity_bytes, + &entry.label, + &seed_hash[..], + entry.identity_index as i64, + network, + ])?; + + // Persist top-ups. + for (&top_up_index, &amount) in &entry.top_ups { + topup_stmt.execute(rusqlite::params![ + id.as_bytes(), + top_up_index as i64, + amount as i64, + ])?; + } + + // Persist DPNS names. + for name in &entry.dpns_names { + dpns_stmt.execute(rusqlite::params![id.as_bytes(), name, network,])?; + } + } + Ok(()) + } + + /// Persist platform address balances into the existing `platform_address_balances` table. + fn persist_platform_addresses( + tx: &rusqlite::Transaction, + seed_hash: &[u8; 32], + network: &str, + addrs: &PlatformAddressChangeSet, + ) -> Result<(), rusqlite::Error> { + let mut stmt = tx.prepare_cached( + "INSERT OR REPLACE INTO platform_address_balances + (seed_hash, address, balance, nonce, network, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, strftime('%s','now'))", + )?; + + for (addr, entry) in &addrs.addresses { + stmt.execute(rusqlite::params![ + &seed_hash[..], + addr.as_bytes(), + entry.credit_balance as i64, + entry.nonce.unwrap_or(0) as i64, + network, + ])?; + } + Ok(()) + } + + /// Persist asset lock changeset into the existing `asset_lock_transaction` table. + fn persist_asset_locks( + tx: &rusqlite::Transaction, + seed_hash: &[u8; 32], + network: &str, + asset_locks: &AssetLockChangeSet, + ) -> Result<(), rusqlite::Error> { + let mut stmt = tx.prepare_cached( + "INSERT OR REPLACE INTO asset_lock_transaction + (tx_id, output_index, transaction_data, amount, identity_id, wallet, network, + chain_locked_height, account_index, funding_type, identity_index, proof_data) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + )?; + + for (out_point, entry) in &asset_locks.asset_locks { + let raw = serialize(&entry.transaction); + // Encode chain-lock status as a height sentinel. + let chain_locked_height: Option = if entry.status == AssetLockStatus::ChainLocked { + Some(0) // chain-locked but exact height unknown from changeset + } else { + None + }; + + // Serialize AssetLockProof using bincode. + let proof_bytes: Option> = entry.proof.as_ref().map(|p| { + bincode::encode_to_vec(p, bincode::config::standard()) + .expect("AssetLockProof bincode encoding should not fail") + }); + + // Map AssetLockFundingType to integer discriminant. + let funding_type_int: i64 = funding_type_to_i64(entry.funding_type); + + stmt.execute(rusqlite::params![ + out_point.txid.as_byte_array(), + out_point.vout as i64, + &raw, + entry.amount_duffs as i64, + None::>, // identity_id: not tracked in changeset + &seed_hash[..], + network, + chain_locked_height, + entry.account_index as i64, + funding_type_int, + entry.identity_index as i64, + proof_bytes, + ])?; + } + Ok(()) + } +} + +impl PlatformWalletPersistence for SqliteWalletPersister { + fn load( + &self, + wallet_id: WalletId, + ) -> Result> { + let seed_hash = wallet_id; + let conn = self.db.shared_connection(); + let guard = conn.lock().unwrap(); + + // -- Load chain height --------------------------------------------------- + let chain = { + let maybe_height: Option = guard + .query_row( + "SELECT last_terminal_block FROM wallet + WHERE seed_hash = ?1 AND network = ?2", + rusqlite::params![&seed_hash[..], &self.network], + |row| row.get(0), + ) + .ok(); + + maybe_height.filter(|&h| h > 0).map(|h| ChainChangeSet { + height: Some(h as u32), + block_hash: None, + }) + }; + + // -- Load transactions --------------------------------------------------- + let transactions = { + let mut stmt = guard.prepare( + "SELECT txid, raw_transaction, height, block_hash, + timestamp, net_amount, fee, label, status + FROM wallet_transactions + WHERE seed_hash = ?1 AND network = ?2", + )?; + + let mut txs = BTreeMap::new(); + let mut rows = stmt.query(rusqlite::params![&seed_hash[..], &self.network])?; + while let Some(row) = rows.next()? { + let txid_bytes: Vec = row.get(0)?; + let raw: Vec = row.get(1)?; + let height: Option = row.get(2)?; + let block_hash_bytes: Option> = row.get(3)?; + let timestamp: i64 = row.get(4)?; + let net_amount: i64 = row.get(5)?; + let fee: Option = row.get(6)?; + let label: Option = row.get(7)?; + let status: i32 = row.get(8)?; + + let Ok(txid) = Txid::from_slice(&txid_bytes) else { + continue; + }; + let Ok(transaction) = deserialize::(&raw) else { + continue; + }; + let block_hash = block_hash_bytes + .as_deref() + .and_then(|b| BlockHash::from_slice(b).ok()); + + txs.insert( + txid, + TransactionEntry { + transaction, + block_height: height.map(|h| h as u32), + block_hash, + timestamp: timestamp as u64, + net_amount, + fee: fee.map(|f| f as u64), + label, + is_instant_locked: status == 1, + is_chain_locked: status == 2, + }, + ); + } + + if txs.is_empty() { + None + } else { + Some(TransactionChangeSet { transactions: txs }) + } + }; + + // -- Load UTXOs ---------------------------------------------------------- + let utxos = { + let mut stmt = + guard.prepare("SELECT txid, vout, value FROM utxos WHERE network = ?1")?; + + let mut added = BTreeMap::new(); + let mut rows = stmt.query(rusqlite::params![&self.network])?; + while let Some(row) = rows.next()? { + let txid_bytes: Vec = row.get(0)?; + let vout: i64 = row.get(1)?; + let value: i64 = row.get(2)?; + + let Ok(txid) = Txid::from_slice(&txid_bytes) else { + continue; + }; + let outpoint = OutPoint { + txid, + vout: vout as u32, + }; + added.insert(outpoint, value as u64); + } + + if added.is_empty() { + None + } else { + Some(UtxoChangeSet { + added, + spent: BTreeSet::new(), + }) + } + }; + + // -- Load balance ------------------------------------------------------- + let balance = { + let row: Option<(i64, i64)> = guard + .query_row( + "SELECT confirmed_balance, unconfirmed_balance FROM wallet + WHERE seed_hash = ?1 AND network = ?2", + rusqlite::params![&seed_hash[..], &self.network], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .ok(); + + row.and_then(|(confirmed, unconfirmed)| { + if confirmed == 0 && unconfirmed == 0 { + None + } else { + Some(dash_sdk::dpp::key_wallet::changeset::BalanceChangeSet { + spendable_delta: confirmed, + unconfirmed_delta: unconfirmed, + immature_delta: 0, + locked_delta: 0, + }) + } + }) + }; + + // -- Load accounts (last_revealed indices) ---------------------------- + let accounts = { + // Table may not exist yet if persist() has never been called. + let table_exists: bool = guard + .query_row( + "SELECT COUNT(*) FROM sqlite_master + WHERE type='table' AND name='wallet_account_state'", + [], + |row| row.get::<_, i64>(0), + ) + .map(|c| c > 0) + .unwrap_or(false); + + if table_exists { + let mut stmt = guard.prepare( + "SELECT account_index, path_reference, last_revealed + FROM wallet_account_state + WHERE seed_hash = ?1 AND network = ?2", + )?; + let mut last_revealed = BTreeMap::new(); + let mut rows = stmt.query(rusqlite::params![&seed_hash[..], &self.network])?; + while let Some(row) = rows.next()? { + let account_index: i64 = row.get(0)?; + let path_ref_val: i64 = row.get(1)?; + let revealed: i64 = row.get(2)?; + if let Some(path_ref) = derivation_path_reference_from_u32(path_ref_val as u32) + { + last_revealed.insert((account_index as u32, path_ref), revealed as u32); + } + } + if last_revealed.is_empty() { + None + } else { + Some(AccountChangeSet { last_revealed }) + } + } else { + None + } + }; + + // -- Load identities --------------------------------------------------- + let identities = { + let mut stmt = guard.prepare( + "SELECT i.id, i.data, i.wallet_index, i.alias, t.top_up_index, t.amount + FROM identity i + LEFT JOIN top_up t ON i.id = t.identity_id + WHERE i.wallet = ?1 AND i.is_local = 1 AND i.network = ?2 + AND i.data IS NOT NULL + ORDER BY i.id", + )?; + + let mut map: BTreeMap = BTreeMap::new(); + let mut rows = stmt.query(rusqlite::params![&seed_hash[..], &self.network])?; + while let Some(row) = rows.next()? { + let id_bytes: Vec = row.get(0)?; + let data: Vec = row.get(1)?; + let wallet_index: Option = row.get(2)?; + let alias: Option = row.get(3)?; + let top_up_index: Option = row.get(4)?; + let top_up_amount: Option = row.get(5)?; + + let Ok(id) = Identifier::from_bytes(&id_bytes) else { + continue; + }; + let Ok(identity) = + dash_sdk::dpp::identity::Identity::deserialize_from_bytes_no_limit(&data) + else { + continue; + }; + + let entry = map.entry(id).or_insert_with(|| IdentityEntry { + identity, + identity_index: wallet_index.unwrap_or(0) as u32, + label: alias, + last_updated_balance_block_time: None, + last_synced_keys_block_time: None, + dpns_names: Vec::new(), + top_ups: BTreeMap::new(), + }); + + // Accumulate top-ups from the JOIN rows. + if let (Some(ti), Some(ta)) = (top_up_index, top_up_amount) { + entry.top_ups.insert(ti as u32, ta as u64); + } + } + + // Load DPNS names (table may not exist). + let dpns_table_exists: bool = guard + .query_row( + "SELECT COUNT(*) FROM sqlite_master + WHERE type='table' AND name='wallet_identity_dpns_names'", + [], + |row| row.get::<_, i64>(0), + ) + .map(|c| c > 0) + .unwrap_or(false); + + if dpns_table_exists { + let mut dpns_stmt = guard.prepare( + "SELECT identity_id, name FROM wallet_identity_dpns_names + WHERE network = ?1", + )?; + let mut rows = dpns_stmt.query(rusqlite::params![&self.network])?; + while let Some(row) = rows.next()? { + let id_bytes: Vec = row.get(0)?; + let name: String = row.get(1)?; + if let Ok(id) = Identifier::from_bytes(&id_bytes) + && let Some(entry) = map.get_mut(&id) + && !entry.dpns_names.contains(&name) + { + entry.dpns_names.push(name); + } + } + } + + if map.is_empty() { + None + } else { + Some(IdentityChangeSet { identities: map }) + } + }; + + // -- Load platform address balances ------------------------------------ + let platform_addresses = { + let mut stmt = guard.prepare( + "SELECT address, balance, nonce FROM platform_address_balances + WHERE seed_hash = ?1 AND network = ?2", + )?; + let mut addresses = BTreeMap::new(); + let mut rows = stmt.query(rusqlite::params![&seed_hash[..], &self.network])?; + while let Some(row) = rows.next()? { + let addr_bytes: Vec = row.get(0)?; + let credit_balance: i64 = row.get(1)?; + let nonce: i64 = row.get(2)?; + + let Ok(addr) = PlatformP2PKHAddress::from_slice(&addr_bytes) else { + continue; + }; + addresses.insert( + addr, + PlatformAddressEntry { + credit_balance: credit_balance as u64, + nonce: if nonce > 0 { Some(nonce as u64) } else { None }, + }, + ); + } + if addresses.is_empty() { + None + } else { + Some(PlatformAddressChangeSet { addresses }) + } + }; + + // -- Load asset locks --------------------------------------------------- + let asset_locks = { + let mut stmt = guard.prepare( + "SELECT tx_id, output_index, transaction_data, amount, identity_id, + chain_locked_height, instant_lock_data, + account_index, funding_type, identity_index, proof_data + FROM asset_lock_transaction + WHERE wallet = ?1 AND network = ?2", + )?; + let mut locks = BTreeMap::new(); + let mut rows = stmt.query(rusqlite::params![&seed_hash[..], &self.network])?; + while let Some(row) = rows.next()? { + let txid_bytes: Vec = row.get(0)?; + let output_index: Option = row.get(1)?; + let raw: Vec = row.get(2)?; + let amount: i64 = row.get(3)?; + let identity_id_bytes: Option> = row.get(4)?; + let chain_locked_height: Option = row.get(5)?; + let islock_bytes: Option> = row.get(6)?; + let account_index: Option = row.get(7)?; + let funding_type_int: Option = row.get(8)?; + let identity_index: Option = row.get(9)?; + let proof_bytes: Option> = row.get(10)?; + + let Ok(txid) = Txid::from_slice(&txid_bytes) else { + continue; + }; + let Ok(transaction) = deserialize::(&raw) else { + continue; + }; + let _identity_id = identity_id_bytes + .as_deref() + .and_then(|b| Identifier::from_bytes(b).ok()); + + let vout = output_index.unwrap_or(0) as u32; + let out_point = OutPoint { txid, vout }; + + let funding_type = funding_type_int + .and_then(funding_type_from_i64) + .unwrap_or(AssetLockFundingType::IdentityRegistration); + + // Deserialize proof from bincode bytes, if present. + let proof: Option = proof_bytes.and_then(|bytes| { + bincode::decode_from_slice::( + &bytes, + bincode::config::standard(), + ) + .ok() + .map(|(p, _)| p) + }); + + locks.insert( + out_point, + AssetLockEntry { + out_point, + transaction, + account_index: account_index.unwrap_or(0) as u32, + funding_type, + identity_index: identity_index.unwrap_or(0) as u32, + amount_duffs: amount as u64, + status: if chain_locked_height.is_some() { + AssetLockStatus::ChainLocked + } else if islock_bytes.is_some() { + AssetLockStatus::InstantSendLocked + } else { + AssetLockStatus::Broadcast + }, + proof, + }, + ); + } + if locks.is_empty() { + None + } else { + Some(AssetLockChangeSet { asset_locks: locks }) + } + }; + + // Build a key-wallet changeset if we have balance data. + let wallet = balance.map( + |bal| dash_sdk::dpp::key_wallet::changeset::WalletChangeSet { + balance: Some(bal), + ..Default::default() + }, + ); + + let cs = PlatformWalletChangeSet { + chain, + transactions, + utxos, + accounts, + identities, + platform_addresses, + asset_locks, + wallet, + ..Default::default() + }; + + if cs.is_empty() { + Ok(PlatformWalletChangeSet::default()) + } else { + Ok(cs) + } + } + + fn store(&self, wallet_id: WalletId, changeset: PlatformWalletChangeSet) { + { + let mut staged = self.staged.lock().unwrap(); + staged + .entry(wallet_id) + .or_insert_with(PlatformWalletChangeSet::default) + .merge(changeset); + } + if matches!(self.strategy, FlushStrategy::Immediate) { + if let Err(e) = self.flush(wallet_id) { + tracing::warn!(error = %e, "Auto-flush after queue failed"); + } + } + } + + fn flush(&self, wallet_id: WalletId) -> Result<(), Box> { + let changeset = { + let mut staged = self.staged.lock().unwrap(); + staged.remove(&wallet_id).unwrap_or_default() + }; + if changeset.is_empty() { + return Ok(()); + } + self.persist_inner(&wallet_id, &changeset) + .map_err(|e| Box::new(e) as Box) + } +} + +impl SqliteWalletPersister { + /// Internal persist implementation used by [`flush`]. + fn persist_inner( + &self, + wallet_id: &WalletId, + changeset: &PlatformWalletChangeSet, + ) -> Result<(), SqlitePersistError> { + let seed_hash = wallet_id; + let conn = self.db.shared_connection(); + let mut guard = conn.lock().unwrap(); + let tx = guard.transaction()?; + + // -- Chain sync state -------------------------------------------------- + // NOTE: block_hash is not persisted yet — the wallet table does not + // have a dedicated block_hash column. Will be added with a schema migration. + if let Some(ChainChangeSet { + height: Some(height), + .. + }) = changeset.chain + { + tx.execute( + "UPDATE wallet SET last_terminal_block = ?1 + WHERE seed_hash = ?2 AND network = ?3", + rusqlite::params![height as i64, &seed_hash[..], &self.network], + )?; + } + + // -- Transactions ------------------------------------------------------ + if let Some(ref txs) = changeset.transactions { + Self::persist_transactions(&tx, &seed_hash, &self.network, txs)?; + } + + // -- UTXOs ------------------------------------------------------------- + if let Some(ref utxos) = changeset.utxos { + Self::persist_utxos(&tx, &self.network, utxos)?; + } + + // -- Key-wallet sub-changesets ----------------------------------------- + if let Some(ref wallet_cs) = changeset.wallet { + // wallet.chain → update wallet height + if let Some(ref chain) = wallet_cs.chain + && let Some(height) = chain.height + { + tx.execute( + "UPDATE wallet SET last_terminal_block = ?1 + WHERE seed_hash = ?2 AND network = ?3", + rusqlite::params![height as i64, &seed_hash[..], &self.network], + )?; + } + + // wallet.transactions → INSERT OR REPLACE transaction records + if let Some(ref kw_txs) = wallet_cs.transactions { + let mut stmt = tx.prepare_cached( + "INSERT OR REPLACE INTO wallet_transactions ( + seed_hash, txid, network, timestamp, height, block_hash, + net_amount, fee, label, is_ours, raw_transaction, status + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + )?; + + for (txid, entry) in &kw_txs.records { + let raw = serialize(&entry.transaction); + let block_hash_bytes: Option> = + entry.block_hash.map(|bh| bh.as_byte_array().to_vec()); + + let status: i32 = if entry.is_chain_locked { + 2 + } else if entry.is_instant_locked { + 1 + } else { + 0 + }; + + stmt.execute(rusqlite::params![ + &seed_hash[..], + txid.as_byte_array(), + &self.network, + entry.timestamp as i64, + entry.block_height.map(|h| h as i64), + block_hash_bytes, + entry.net_amount, + entry.fee.map(|f| f as i64), + &entry.label, + 1i32, + &raw, + status, + ])?; + } + } + + // wallet.utxos → INSERT added, DELETE spent + if let Some(ref kw_utxos) = wallet_cs.utxos { + let mut insert_stmt = tx.prepare_cached( + "INSERT OR IGNORE INTO utxos (txid, vout, address, value, script_pubkey, network) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + )?; + for (outpoint, entry) in &kw_utxos.added { + insert_stmt.execute(rusqlite::params![ + outpoint.txid.as_byte_array(), + outpoint.vout as i64, + entry.address.to_string(), + entry.value as i64, + entry.script_pubkey.as_bytes(), + &self.network, + ])?; + } + + let mut delete_stmt = tx.prepare_cached( + "DELETE FROM utxos WHERE txid = ?1 AND vout = ?2 AND network = ?3", + )?; + for outpoint in &kw_utxos.spent { + delete_stmt.execute(rusqlite::params![ + outpoint.txid.as_byte_array(), + outpoint.vout as i64, + &self.network, + ])?; + } + } + + // wallet.accounts → persist last_revealed indices (key-wallet type) + if let Some(ref kw_accounts) = wallet_cs.accounts { + Self::persist_key_wallet_accounts( + &tx, + &seed_hash, + &self.network, + kw_accounts, + )?; + } + + // wallet.balance → UPDATE wallet balance fields + if let Some(ref bal) = wallet_cs.balance { + // Balance changeset carries deltas; apply them with SQL arithmetic. + tx.execute( + "UPDATE wallet SET + confirmed_balance = MAX(0, confirmed_balance + ?1), + unconfirmed_balance = MAX(0, unconfirmed_balance + ?2), + total_balance = MAX(0, total_balance + ?1 + ?2) + WHERE seed_hash = ?3 AND network = ?4", + rusqlite::params![ + bal.spendable_delta, + bal.unconfirmed_delta, + &seed_hash[..], + &self.network, + ], + )?; + } + } + + // -- Accounts ---------------------------------------------------------- + if let Some(ref accounts) = changeset.accounts { + Self::persist_accounts(&tx, &seed_hash, &self.network, accounts)?; + } + + // -- Contacts ---------------------------------------------------------- + if let Some(ref contacts) = changeset.contacts { + // Sent contact requests + for (from_id, to_id) in contacts.sent_requests.keys() { + tx.execute( + "INSERT OR IGNORE INTO dashpay_contact_requests + (from_identity_id, to_identity_id, network, request_type, status) + VALUES (?1, ?2, ?3, 'sent', 'pending')", + rusqlite::params![from_id.as_bytes(), to_id.as_bytes(), &self.network,], + )?; + } + + // Incoming contact requests + for (from_id, to_id) in contacts.incoming_requests.keys() { + tx.execute( + "INSERT OR IGNORE INTO dashpay_contact_requests + (from_identity_id, to_identity_id, network, request_type, status) + VALUES (?1, ?2, ?3, 'received', 'pending')", + rusqlite::params![from_id.as_bytes(), to_id.as_bytes(), &self.network,], + )?; + } + + // Established contacts + for (owner_id, contact_id) in &contacts.established { + tx.execute( + "INSERT OR IGNORE INTO dashpay_contacts + (owner_identity_id, contact_identity_id, network, contact_status) + VALUES (?1, ?2, ?3, 'accepted')", + rusqlite::params![owner_id.as_bytes(), contact_id.as_bytes(), &self.network,], + )?; + } + } + + // -- Identities -------------------------------------------------------- + if let Some(ref identities) = changeset.identities { + Self::persist_identities(&tx, &seed_hash, &self.network, identities)?; + } + + // -- Platform addresses ------------------------------------------------ + if let Some(ref platform_addrs) = changeset.platform_addresses { + Self::persist_platform_addresses(&tx, &seed_hash, &self.network, platform_addrs)?; + } + + // -- Asset locks ------------------------------------------------------- + if let Some(ref asset_locks) = changeset.asset_locks { + Self::persist_asset_locks(&tx, &seed_hash, &self.network, asset_locks)?; + } + + tx.commit()?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::test_helpers::create_test_database; + + const TEST_WALLET_ID: WalletId = [0u8; 32]; + + fn make_persister(db: Arc) -> SqliteWalletPersister { + SqliteWalletPersister::new(db, "testnet".to_string()) + } + + /// A persister can be created and initialized without error. + #[test] + fn test_initialize_returns_empty_changeset() { + let db = Arc::new(create_test_database().expect("create test db")); + let persister = make_persister(db); + + let cs = persister.load(TEST_WALLET_ID).expect("initialize"); + assert!(cs.is_empty()); + } + + /// Persisting an empty changeset succeeds (no-op transaction). + #[test] + fn test_persist_empty_changeset() { + let db = Arc::new(create_test_database().expect("create test db")); + let persister = make_persister(db); + + let cs = PlatformWalletChangeSet::default(); + persister.store(TEST_WALLET_ID, cs); + persister.flush(TEST_WALLET_ID).expect("flush empty changeset"); + } + + /// Persisting a chain changeset updates the wallet row. + #[test] + fn test_persist_chain_changeset() { + let db = Arc::new(create_test_database().expect("create test db")); + + // Insert a wallet row so the UPDATE has something to hit. + db.execute( + "INSERT INTO wallet (seed_hash, encrypted_seed, salt, nonce, + master_ecdsa_bip44_account_0_epk, uses_password, network) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + rusqlite::params![ + &[0u8; 32][..], + &[0u8; 1][..], + &[0u8; 1][..], + &[0u8; 1][..], + &[0u8; 1][..], + 0i32, + "testnet", + ], + ) + .expect("insert wallet row"); + + let persister = make_persister(db.clone()); + + let cs = PlatformWalletChangeSet { + chain: Some(ChainChangeSet { + height: Some(12345), + block_hash: None, + }), + ..Default::default() + }; + persister.store(TEST_WALLET_ID, cs); + persister.flush(TEST_WALLET_ID).expect("flush chain changeset"); + + // Verify the height was written. + let conn = db.shared_connection(); + let guard = conn.lock().unwrap(); + let stored_height: i64 = guard + .query_row( + "SELECT last_terminal_block FROM wallet + WHERE seed_hash = ?1 AND network = ?2", + rusqlite::params![&[0u8; 32][..], "testnet"], + |row| row.get(0), + ) + .expect("query height"); + assert_eq!(stored_height, 12345); + } + + /// Persisting a UTXO changeset adds and removes rows. + #[test] + fn test_persist_utxo_changeset() { + use dash_sdk::dpp::dashcore::{OutPoint, Txid}; + use std::collections::{BTreeMap, BTreeSet}; + + let db = Arc::new(create_test_database().expect("create test db")); + let persister = make_persister(db.clone()); + + let txid = Txid::from_slice(&[1u8; 32]).unwrap(); + let outpoint = OutPoint { txid, vout: 0 }; + + // Add a UTXO. + let mut added = BTreeMap::new(); + added.insert(outpoint, 50_000u64); + let cs = PlatformWalletChangeSet { + utxos: Some(UtxoChangeSet { + added, + spent: BTreeSet::new(), + }), + ..Default::default() + }; + persister.store(TEST_WALLET_ID, cs); + persister.flush(TEST_WALLET_ID).expect("flush add utxo"); + + // Verify it was inserted. + let conn = db.shared_connection(); + { + let guard = conn.lock().unwrap(); + let count: i64 = guard + .query_row( + "SELECT COUNT(*) FROM utxos WHERE txid = ?1 AND vout = ?2 AND network = ?3", + rusqlite::params![txid.as_byte_array(), 0i64, "testnet"], + |row| row.get(0), + ) + .expect("count utxos"); + assert_eq!(count, 1); + } + + // Now spend it. + let mut spent = BTreeSet::new(); + spent.insert(outpoint); + let cs2 = PlatformWalletChangeSet { + utxos: Some(UtxoChangeSet { + added: BTreeMap::new(), + spent, + }), + ..Default::default() + }; + persister.store(TEST_WALLET_ID, cs2); + persister.flush(TEST_WALLET_ID).expect("flush spend utxo"); + + // Verify it was removed. + { + let guard = conn.lock().unwrap(); + let count: i64 = guard + .query_row( + "SELECT COUNT(*) FROM utxos WHERE txid = ?1 AND vout = ?2 AND network = ?3", + rusqlite::params![txid.as_byte_array(), 0i64, "testnet"], + |row| row.get(0), + ) + .expect("count utxos after spend"); + assert_eq!(count, 0); + } + } + + /// Persist a chain changeset then initialize() to verify round-trip. + #[test] + fn test_initialize_loads_persisted_state() { + use dash_sdk::dpp::dashcore::{OutPoint, Txid}; + use std::collections::{BTreeMap, BTreeSet}; + + let db = Arc::new(create_test_database().expect("create test db")); + + // Insert a wallet row. + db.execute( + "INSERT INTO wallet (seed_hash, encrypted_seed, salt, nonce, + master_ecdsa_bip44_account_0_epk, uses_password, network) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + rusqlite::params![ + &[0u8; 32][..], + &[0u8; 1][..], + &[0u8; 1][..], + &[0u8; 1][..], + &[0u8; 1][..], + 0i32, + "testnet", + ], + ) + .expect("insert wallet row"); + + let persister = make_persister(db.clone()); + + // Persist chain height and a UTXO. + let txid = Txid::from_slice(&[2u8; 32]).unwrap(); + let outpoint = OutPoint { txid, vout: 1 }; + let mut added = BTreeMap::new(); + added.insert(outpoint, 75_000u64); + + let cs = PlatformWalletChangeSet { + chain: Some(ChainChangeSet { + height: Some(99999), + block_hash: None, + }), + utxos: Some(UtxoChangeSet { + added, + spent: BTreeSet::new(), + }), + ..Default::default() + }; + persister.store(TEST_WALLET_ID, cs); + persister.flush(TEST_WALLET_ID).expect("flush changeset"); + + // Now initialize and verify the state was loaded. + let loaded = persister.load(TEST_WALLET_ID).expect("initialize"); + assert!(!loaded.is_empty()); + + // Chain height should match. + let chain = loaded.chain.expect("chain should be loaded"); + assert_eq!(chain.height, Some(99999)); + + // UTXOs should contain the one we added. + let utxos = loaded.utxos.expect("utxos should be loaded"); + assert_eq!(utxos.added.len(), 1); + assert_eq!(utxos.added.get(&outpoint), Some(&75_000u64)); + assert!(utxos.spent.is_empty()); + } +} diff --git a/src/context/connection_status.rs b/src/context/connection_status.rs index d69b19352..3ad807daa 100644 --- a/src/context/connection_status.rs +++ b/src/context/connection_status.rs @@ -178,7 +178,7 @@ impl ConnectionStatus { } } - /// Set the last SPV error message (push-based from SpvManager event handlers). + /// Set the last SPV error message (push-based from SpvEventBridge). pub fn set_spv_last_error(&self, error: Option) { let mut err = self .spv_last_error @@ -194,7 +194,7 @@ impl ConnectionStatus { /// Update SPV connected peer count and maintain `spv_no_peers_since` tracking. /// - /// Called from SpvManager's network event handler when peer count changes. + /// Called from SpvEventBridge when peer count changes. pub fn set_spv_connected_peers(&self, count: u16) { self.spv_connected_peers.store(count, Ordering::Relaxed); let mut since = self @@ -331,11 +331,11 @@ impl ConnectionStatus { /// Build the tooltip string for the connection indicator. /// - /// In SPV mode, fetches sync progress from the [`SpvManager`] to display + /// In SPV mode, fetches sync progress from the [`SpvEventBridge`] 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. + // (spv_event_bridge 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(); @@ -392,7 +392,7 @@ impl ConnectionStatus { "SPV: Error".to_string() } else { app_context - .spv_manager() + .spv_event_bridge() .status() .sync_progress .as_ref() @@ -497,7 +497,7 @@ impl ConnectionStatus { match backend_mode { CoreBackendMode::Spv => { - // SPV status is push-based: SpvManager event handlers call + // SPV status is push-based: SpvEventBridge handlers call // set_spv_status / set_spv_connected_peers / set_spv_last_error // directly, so no polling is needed here. } diff --git a/src/context/contract_token_db.rs b/src/context/contract_token_db.rs index 27b4fafaf..a48366207 100644 --- a/src/context/contract_token_db.rs +++ b/src/context/contract_token_db.rs @@ -158,6 +158,10 @@ impl AppContext { let has_wallet = !wallets.is_empty(); drop(wallets); + if let Ok(mut mapping) = self.wallet_id_mapping.lock() { + mapping.remove_by_seed_hash(seed_hash); + } + self.has_wallet.store(has_wallet, Ordering::Relaxed); Ok(()) diff --git a/src/context/identity_db.rs b/src/context/identity_db.rs index 27bc4c04d..c665af69f 100644 --- a/src/context/identity_db.rs +++ b/src/context/identity_db.rs @@ -18,7 +18,10 @@ impl AppContext { qualified_identity, wallet_and_identity_id_info, self, - ) + )?; + // Sync to platform-wallet IdentityManager (best-effort) + self.sync_identity_to_platform_wallet(qualified_identity); + Ok(()) } /// Updates a local qualified identity in the database @@ -27,7 +30,10 @@ impl AppContext { qualified_identity: &QualifiedIdentity, ) -> Result<()> { self.db - .update_local_qualified_identity(qualified_identity, self) + .update_local_qualified_identity(qualified_identity, self)?; + // Sync to platform-wallet IdentityManager (best-effort) + self.sync_identity_to_platform_wallet(qualified_identity); + Ok(()) } /// Sets the alias for an identity diff --git a/src/context/mod.rs b/src/context/mod.rs index 9c6ad5a37..b50d14f20 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -20,8 +20,10 @@ use crate::model::password_info::PasswordInfo; use crate::model::proof_log_item::RequestType; use crate::model::wallet::single_key::{SingleKeyHash, SingleKeyWallet}; use crate::model::wallet::{Wallet, WalletSeedHash}; +use crate::platform_wallet_bridge::{PlatformWalletManager, WalletIdMapping}; use crate::sdk_wrapper::initialize_sdk; -use crate::spv::{CoreBackendMode, SpvManager}; +use crate::spv::CoreBackendMode; +use crate::spv::event_bridge::SpvEventBridge; use crate::utils::tasks::TaskManager; use arc_swap::ArcSwap; use connection_status::ConnectionStatus; @@ -29,10 +31,9 @@ use crossbeam_channel::{Receiver, Sender}; use dash_sdk::Sdk; use dash_sdk::dapi_client::AddressList; use dash_sdk::dashcore_rpc::{Auth, Client, RpcApi}; -use dash_sdk::dpp::dashcore::{Address, Network, Txid}; +use dash_sdk::dpp::dashcore::{Address, Network}; #[cfg(any(test, feature = "testing"))] use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; -use dash_sdk::dpp::prelude::AssetLockProof; use dash_sdk::dpp::state_transition::StateTransitionSigningOptions; use dash_sdk::dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions; use dash_sdk::dpp::system_data_contracts::{SystemDataContract, load_system_data_contract}; @@ -52,6 +53,23 @@ use crate::model::settings::Settings; const ANIMATION_REFRESH_TIME: std::time::Duration = std::time::Duration::from_millis(100); +/// Wrapper that provides a `Debug` impl for types that don't implement it. +/// Prints the type name instead of the value. +pub(crate) struct DebugWrapper(pub T); + +impl std::fmt::Debug for DebugWrapper { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", std::any::type_name::()) + } +} + +impl std::ops::Deref for DebugWrapper { + type Target = T; + fn deref(&self) -> &T { + &self.0 + } +} + /// A guard that ensures settings cache invalidation happens atomically /// /// This guard holds a write lock on the cached settings, preventing reads @@ -80,9 +98,16 @@ pub struct AppContext { pub(crate) has_wallet: AtomicBool, pub(crate) wallets: RwLock>>>, pub(crate) single_key_wallets: RwLock>>>, + /// New platform-wallet manager — multi-wallet coordinator. + /// Coexists with `wallets` during migration; callers will be incrementally + /// switched from the old `wallets` map to this manager. + #[allow(dead_code)] // Used during incremental migration to PlatformWallet + pub(crate) wallet_manager: Arc>, + /// Bidirectional mapping between `WalletSeedHash` (old key) and `WalletId` (new key). + /// Protected by a std `Mutex` since updates happen infrequently (wallet add/remove). + pub(crate) wallet_id_mapping: Mutex, #[allow(dead_code)] // May be used for password validation pub(crate) password_info: Option, - pub(crate) transactions_waiting_for_finality: Mutex>>, /// Whether to animate the UI elements. /// /// This is used to control animations in the UI, such as loading spinners or transitions. @@ -93,7 +118,10 @@ pub struct AppContext { cached_settings: RwLock>, // subtasks started by the app context, used for graceful shutdown pub(crate) subtasks: Arc, - pub(crate) spv_manager: Arc, + pub(crate) spv_event_bridge: Arc, + /// Cancellation token for the current SPV session. Cancelled in `stop_spv()` + /// to signal `SpvRuntime::run()` to exit gracefully. + spv_cancel_token: Mutex>, core_backend_mode: AtomicU8, /// Tracks the connection status to currently active network pub(crate) connection_status: Arc, @@ -256,6 +284,15 @@ impl AppContext { } }; + // Create the shared persister and PlatformWalletManager with the SDK + let persister: Arc = Arc::new( + crate::changeset::SqliteWalletPersister::new(db.clone(), network.to_string()), + ); + let wallet_manager = Arc::new(DebugWrapper(PlatformWalletManager::new( + Arc::new(sdk.clone()), + persister, + ))); + let wallets: BTreeMap<_, _> = match db.get_wallets(&network) { Ok(w) => w, Err(e) => { @@ -291,25 +328,14 @@ impl AppContext { false => AtomicBool::new(true), // Animations are enabled by default }; - let spv_manager = match SpvManager::new( - &data_dir, - network, - Arc::clone(&config_lock), - subtasks.clone(), - ) { - Ok(manager) => manager, - Err(err) => { - tracing::error!(?err, ?network, "Failed to initialize SPV manager"); - return None; - } - }; - - // Load the use_local_spv_node setting and apply to SPV manager - let use_local_spv_node = db.get_use_local_spv_node().unwrap_or(false); - spv_manager.set_use_local_node(use_local_spv_node); - - // Wire up push-based SPV status updates to ConnectionStatus - spv_manager.set_connection_status(Arc::clone(&connection_status)); + // Create the SpvEventBridge. The reconcile channel is created with a + // dummy sender; start_spv() calls new_reconcile_channel() to wire up + // the real pipeline for each SPV session. + let (reconcile_tx, _) = tokio::sync::mpsc::channel(1); + let spv_event_bridge = Arc::new(SpvEventBridge::new( + Arc::clone(&connection_status), + reconcile_tx, + )); // Load the core backend mode from settings, defaulting to RPC if not set let saved_core_backend_mode = db @@ -355,12 +381,14 @@ impl AppContext { has_wallet: (!wallets.is_empty() || !single_key_wallets.is_empty()).into(), wallets: RwLock::new(wallets), single_key_wallets: RwLock::new(single_key_wallets), + wallet_manager, + wallet_id_mapping: Mutex::new(WalletIdMapping::new()), password_info, - transactions_waiting_for_finality: Mutex::new(BTreeMap::new()), animate, cached_settings: RwLock::new(None), subtasks, - spv_manager, + spv_event_bridge, + spv_cancel_token: Mutex::new(None), core_backend_mode: AtomicU8::new(saved_core_backend_mode), connection_status, pending_wallet_selection: Mutex::new(None), @@ -819,6 +847,11 @@ impl AppContext { pub fn dashpay_contract_id(&self) -> Identifier { self.dashpay_contract.id() } + + /// Returns a reference to the wallet-ID mapping (seed hash ↔ wallet ID). + pub fn wallet_id_mapping(&self) -> &Mutex { + &self.wallet_id_mapping + } } /// Returns the default platform version for the given network. diff --git a/src/context/shielded.rs b/src/context/shielded.rs index f61c05d7a..946d6bda8 100644 --- a/src/context/shielded.rs +++ b/src/context/shielded.rs @@ -99,43 +99,24 @@ impl AppContext { /// 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. + /// Updates 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: &WalletSeedHash, from_address: &dash_sdk::dpp::address_funds::PlatformAddress, ) { - let wallets = self.wallets.read().unwrap_or_else(|e| e.into_inner()); - let wallet_arc = match wallets.get(seed_hash) { - Some(w) => w.clone(), - None => return, - }; - drop(wallets); - - let mut wallet = wallet_arc.write().unwrap_or_else(|e| e.into_inner()); - // 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 core_addr = from_address.to_address_with_network(self.network); + if let Ok(Some((balance, nonce))) = + self.db + .get_platform_address_info(seed_hash, &core_addr, &self.network) + { let _ = self.db.set_platform_address_info( seed_hash, &core_addr, balance, - new_nonce, + nonce + 1, &self.network, ); } @@ -150,29 +131,18 @@ impl AppContext { from_address: &dash_sdk::dpp::address_funds::PlatformAddress, nonce: u32, ) { - let wallets = self.wallets.read().unwrap_or_else(|e| e.into_inner()); - let wallet_arc = match wallets.get(seed_hash) { - Some(w) => w.clone(), - None => return, - }; - drop(wallets); - - let mut wallet = wallet_arc.write().unwrap_or_else(|e| e.into_inner()); - 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 = nonce; - let _ = self.db.set_platform_address_info( - seed_hash, - core_addr, - info.balance, - nonce, - &self.network, - ); - break; - } + let core_addr = from_address.to_address_with_network(self.network); + if let Ok(Some((balance, _old_nonce))) = + self.db + .get_platform_address_info(seed_hash, &core_addr, &self.network) + { + let _ = self.db.set_platform_address_info( + seed_hash, + &core_addr, + balance, + nonce, + &self.network, + ); } } diff --git a/src/context/transaction_processing.rs b/src/context/transaction_processing.rs index 0c315e4ae..4c61d0755 100644 --- a/src/context/transaction_processing.rs +++ b/src/context/transaction_processing.rs @@ -1,160 +1,13 @@ use super::AppContext; use crate::backend_task::error::TaskError; -use crate::model::wallet::{Wallet, WalletSeedHash}; -use crate::spv::CoreBackendMode; use dash_sdk::Sdk; -use dash_sdk::dashcore_rpc::RpcApi; -use dash_sdk::dpp::dashcore::hashes::Hash; use dash_sdk::dpp::dashcore::transaction::special_transaction::TransactionPayload::AssetLockPayloadType; use dash_sdk::dpp::dashcore::{Address, InstantLock, OutPoint, Transaction, TxOut, Txid}; use dash_sdk::dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; use dash_sdk::dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; use dash_sdk::dpp::prelude::{AssetLockProof, CoreBlockHeight}; -use std::collections::{BTreeMap, HashMap}; -use std::sync::{Arc, RwLock}; impl AppContext { - /// Broadcast a raw transaction via Core RPC or SPV depending on backend mode. - pub(crate) async fn broadcast_raw_transaction( - &self, - tx: &Transaction, - ) -> Result { - match self.core_backend_mode() { - CoreBackendMode::Rpc => self - .core_client - .read()? - .send_raw_transaction(tx) - .map_err(TaskError::from), - CoreBackendMode::Spv => { - self.spv_manager - .broadcast_transaction(tx) - .await - .map_err(|e| TaskError::SpvBroadcastFailed { detail: e })?; - Ok(tx.txid()) - } - } - } - - /// Wait for an asset lock proof (InstantLock or ChainLock) for the given transaction. - /// - /// Polls `transactions_waiting_for_finality` until a proof appears, with a - /// backend-mode-dependent timeout (SPV: 5 min, RPC: 2 min). Cleans up the - /// tracking entry on both success and timeout. - pub(crate) async fn wait_for_asset_lock_proof( - &self, - tx_id: Txid, - ) -> Result { - use tokio::time::Duration; - - let timeout_duration = match self.core_backend_mode() { - CoreBackendMode::Spv => Duration::from_secs(300), - CoreBackendMode::Rpc => Duration::from_secs(120), - }; - - match tokio::time::timeout(timeout_duration, async { - loop { - { - let proofs = self.transactions_waiting_for_finality.lock()?; - if let Some(Some(proof)) = proofs.get(&tx_id) { - return Ok::<_, TaskError>(proof.clone()); - } - } - tokio::time::sleep(Duration::from_millis(200)).await; - } - }) - .await - { - Ok(Ok(proof)) => { - if let Ok(mut proofs) = self.transactions_waiting_for_finality.lock() { - proofs.remove(&tx_id); - } - Ok(proof) - } - Ok(Err(e)) => { - // Lock poisoned — return immediately instead of spinning - Err(e) - } - Err(_) => { - if let Ok(mut proofs) = self.transactions_waiting_for_finality.lock() { - proofs.remove(&tx_id); - } - Err(TaskError::ConfirmationTimeout) - } - } - } - - /// Broadcast an asset lock transaction with the store-before-broadcast - /// safety pattern, removing spent UTXOs only after a successful broadcast. - /// - /// Performs the following steps in order: - /// 1. Register the transaction for finality tracking. - /// 2. Store the asset lock in the DB **before** broadcast — prevents an SPV - /// InstantSend race where the finality proof arrives before the DB row. - /// 3. Broadcast the transaction. On failure: clean up the finality tracker - /// and pre-stored DB row; UTXOs are **not** removed. - /// 4. On success: remove spent UTXOs from the wallet and DB. - /// - /// # UTXO selection race window - /// - /// Callers select UTXOs while holding the wallet write lock, then drop it - /// before calling this method (which re-acquires the lock in step 4). During - /// the gap — covering steps 1–3 and the network broadcast — another task - /// could theoretically select the same UTXOs via `select_unspent_utxos_for`, - /// leading to a double-spend attempt on-chain (which Core would reject). - /// - /// We cannot hold the `std::sync::RwLock` write guard across the async - /// broadcast because the guard is `!Send` and tokio tasks require `Send` - /// futures. Fixing this properly requires either migrating to - /// `tokio::sync::RwLock` (large refactor) or adding a UTXO reservation - /// mechanism. In practice the risk is negligible: users would have to - /// trigger two fund operations on the same wallet near-simultaneously. - /// - /// Returns the [`Txid`] of the broadcast transaction. - pub(crate) async fn broadcast_and_commit_asset_lock( - &self, - asset_lock_transaction: &Transaction, - amount: u64, - wallet_seed_hash: &WalletSeedHash, - wallet: &Arc>, - used_utxos: &BTreeMap, - ) -> Result { - let tx_id = asset_lock_transaction.txid(); - - // Step 1: Register for finality tracking. - { - let mut proofs = self.transactions_waiting_for_finality.lock()?; - proofs.insert(tx_id, None); - } - - // Step 2: Store the asset lock transaction in the database *before* broadcast. - self.db.store_asset_lock_transaction( - asset_lock_transaction, - amount, - None, - wallet_seed_hash, - self.network, - )?; - - // Step 3: Broadcast. On failure, clean up DB row and finality tracker. - if let Err(e) = self.broadcast_raw_transaction(asset_lock_transaction).await { - if let Ok(mut proofs) = self.transactions_waiting_for_finality.try_lock() { - proofs.remove(&tx_id); - } - let _ = self.db.delete_asset_lock_transaction(tx_id.as_byte_array()); - return Err(e); - } - - // Step 4: Broadcast succeeded — commit UTXO removal now. - { - let mut wallet_guard = wallet.write()?; - wallet_guard - .remove_selected_utxos(used_utxos, &self.db, self.network) - .map_err(|e| TaskError::UtxoUpdateFailed { detail: e })?; - } - - Ok(tx_id) - } - pub(crate) fn received_transaction_finality( &self, tx: &Transaction, @@ -167,12 +20,12 @@ impl AppContext { // Identify the wallets associated with the transaction let wallets = self.wallets.read()?; for wallet_arc in wallets.values() { - let mut wallet = wallet_arc.write()?; + let wallet = wallet_arc.write()?; for (vout, tx_out) in tx.output.iter().enumerate() { let address = if let Ok(output_addr) = Address::from_script(&tx_out.script_pubkey, self.network) { - if wallet.known_addresses.contains_key(&output_addr) { + if wallet.has_address(&output_addr) { output_addr } else { continue; @@ -180,34 +33,15 @@ impl AppContext { } else { continue; }; - self.db.insert_utxo( - tx.txid().as_byte_array(), - vout as u32, - &address, - tx_out.value, - &tx_out.script_pubkey.to_bytes(), - self.network, - )?; - self.db - .add_to_address_balance(&wallet.seed_hash(), &address, tx_out.value)?; + // UTXOs and address balances are persisted via the changeset + // path (SPV adapter stages them, auto-flushed via + // FlushStrategy::Immediate). Only in-memory wallet state and + // app-level metadata (DashPay contacts) are updated here. - // Create the OutPoint and insert it into the wallet.utxos entry + // Collect the outpoint (UTXOs tracked by ManagedWalletInfo) let out_point = OutPoint::new(tx.txid(), vout as u32); - wallet - .utxos - .entry(address.clone()) - .or_insert_with(HashMap::new) // Initialize inner HashMap if needed - .insert(out_point, tx_out.clone()); // Insert the TxOut at the OutPoint - - // Collect the outpoint wallet_outpoints.push((out_point, tx_out.clone(), address.clone())); - wallet - .address_balances - .entry(address.clone()) - .and_modify(|balance| *balance += tx_out.value) - .or_insert(tx_out.value); - // Check if this is a DashPay contact payment if let Ok(Some((owner_id, contact_id, address_index))) = self.db.get_dashpay_address_mapping(&address) @@ -282,23 +116,15 @@ impl AppContext { }) }; - { - let mut transactions = self.transactions_waiting_for_finality.lock()?; - - if let Some(asset_lock_proof) = transactions.get_mut(&tx.txid()) { - *asset_lock_proof = proof.clone(); - } - } - // Identify the wallet associated with the transaction let wallets = self.wallets.read()?; for wallet_arc in wallets.values() { - let mut wallet = wallet_arc.write()?; + let wallet = wallet_arc.read()?; // Check if any of the addresses in the transaction outputs match the wallet's known addresses let matches_wallet = payload.credit_outputs.iter().any(|tx_out| { if let Ok(output_addr) = Address::from_script(&tx_out.script_pubkey, self.network) { - wallet.known_addresses.contains_key(&output_addr) + wallet.has_address(&output_addr) } else { false } @@ -321,18 +147,18 @@ impl AppContext { self.network, )?; - let first = payload - .credit_outputs - .first() - .ok_or(TaskError::AssetLockNoCreditOutputs)?; - - let address = Address::from_script(&first.script_pubkey, self.network) - .map_err(|e| TaskError::AssetLockAddressDerivationFailed { source: e })?; - - // Add the asset lock to the wallet's unused_asset_locks - wallet - .unused_asset_locks - .push((tx.clone(), address, amount, islock, proof)); + // Register with PlatformWallet's AssetLockManager + if let Some(pw) = wallet.platform_wallet.as_ref() { + pw.asset_locks().recover_asset_lock_blocking( + tx.clone(), + amount, + 0, + platform_wallet::AssetLockFundingType::IdentityRegistration, + 0, + dash_sdk::dpp::dashcore::OutPoint::new(tx.txid(), 0), + proof.clone(), + ); + } break; // Exit the loop after updating the relevant wallet } diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index e8c739915..e71bea2c2 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -1,32 +1,46 @@ use super::AppContext; -use super::get_transaction_info; use crate::backend_task::error::TaskError; use crate::database::is_unique_constraint_violation; +use crate::model::qualified_identity::encrypted_key_storage::{ + PrivateKeyData as QIPrivateKeyData, WalletDerivationPath, +}; +use crate::model::qualified_identity::{PrivateKeyTarget, QualifiedIdentity}; use crate::model::wallet::{ - AddressInfo as WalletAddressInfo, DerivationPathHelpers, DerivationPathReference, - DerivationPathType, TransactionStatus, Wallet, WalletSeedHash, WalletTransaction, + DerivationPathHelpers, DerivationPathReference, DerivationPathType, Wallet, WalletSeedHash, +}; +use crate::platform_wallet_bridge::{ + ManagedDpnsNameInfo, ManagedIdentityStatus, ManagedKeyStorage, ManagedPrivateKeyData, + PlatformWallet, }; -use crate::spv::{AssetLockFinalityEvent, CoreBackendMode, SpvManager}; -use dash_sdk::dpp::dashcore::hashes::Hash; +use crate::spv::CoreBackendMode; +use crate::spv::event_bridge::SpvEventBridge; use dash_sdk::dpp::dashcore::{Address, Network}; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::key_wallet::Network as WalletNetwork; use dash_sdk::dpp::key_wallet::account::AccountType; use dash_sdk::dpp::key_wallet::bip32::{ChildNumber, DerivationPath}; -use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::{ - ManagedWalletInfo, wallet_info_interface::WalletInfoInterface, -}; +use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use std::sync::atomic::Ordering; use std::sync::{Arc, RwLock}; +use zeroize::Zeroizing; impl AppContext { - pub fn spv_manager(&self) -> &Arc { - &self.spv_manager + pub fn spv_event_bridge(&self) -> &Arc { + &self.spv_event_bridge } pub fn clear_spv_data(&self) -> Result<(), TaskError> { - self.spv_manager - .clear_data_dir() - .map_err(|e| TaskError::SpvClearDataFailed { detail: e }) + // Delete the SPV data directory directly (it's just files on disk). + let spv_dir = self.data_dir.join("spv"); + if spv_dir.exists() { + std::fs::remove_dir_all(&spv_dir).map_err(|e| TaskError::SpvClearDataFailed { + detail: format!("Failed to remove SPV data directory: {e}"), + })?; + } + std::fs::create_dir_all(&spv_dir).map_err(|e| TaskError::SpvClearDataFailed { + detail: format!("Failed to re-create SPV data directory: {e}"), + })?; + Ok(()) } pub fn clear_network_database(&self) -> Result<(), TaskError> { @@ -40,47 +54,154 @@ impl AppContext { single_key_wallets.clear(); } + if let Ok(mut mapping) = self.wallet_id_mapping.lock() { + *mapping = crate::platform_wallet_bridge::WalletIdMapping::new(); + } + self.has_wallet.store(false, Ordering::Relaxed); Ok(()) } + /// Get a `PlatformWallet` by `WalletSeedHash`. + /// + /// Returns `None` if the wallet doesn't exist or is locked (no platform_wallet). + pub(crate) fn get_platform_wallet( + &self, + seed_hash: &WalletSeedHash, + ) -> Option> { + self.wallets + .read() + .ok() + .and_then(|wallets| wallets.get(seed_hash).cloned()) + .and_then(|w| w.read().ok().and_then(|g| g.platform_wallet.clone())) + } + + /// Get any available `PlatformWallet`. + /// + /// Useful when the caller needs SDK access but doesn't care which wallet + /// instance is used (e.g. DPNS resolution, identity fetches where the + /// wallet derivation index is irrelevant). + pub(crate) fn first_available_platform_wallet(&self) -> Option> { + self.wallets.read().ok().and_then(|wallets| { + wallets + .values() + .find_map(|w| w.read().ok().and_then(|g| g.platform_wallet.clone())) + }) + } + + /// Get a `PlatformWallet` by seed hash, or return `TaskError::WalletNotFound`. + /// + /// Convenience wrapper for backend tasks that need the platform wallet. + pub(crate) fn require_platform_wallet( + &self, + seed_hash: &WalletSeedHash, + ) -> Result, TaskError> { + self.get_platform_wallet(seed_hash) + .ok_or(TaskError::WalletNotFound) + } + + /// Flush queued changesets from a `PlatformWallet` to SQLite. + /// + /// Calls [`PlatformWallet::flush_persist`] which delegates to the + /// attached persister's `flush()`. If no persister is attached or nothing + /// is queued the call is a no-op. Persistence failures are logged but + /// never propagated — the in-memory state remains authoritative. + /// + /// With [`FlushStrategy::Immediate`](crate::changeset::FlushStrategy::Immediate) + /// (the default), each `queue()` call auto-flushes, making explicit calls + /// here unnecessary for most code paths. This method remains available for + /// batch operations that use [`FlushStrategy::Manual`](crate::changeset::FlushStrategy::Manual). + #[allow(dead_code)] + pub(crate) fn flush_wallet_persistence(&self, platform_wallet: &PlatformWallet) { + if let Err(e) = platform_wallet.flush_persist() { + tracing::warn!( + error = %e, + "Failed to flush wallet persistence" + ); + } + } + + /// Get a `PlatformWallet` for the given `QualifiedIdentity`. + /// + /// Resolves the wallet seed hash from the identity's key derivation paths, + /// then looks it up in the platform wallet bridge map. + pub(crate) fn platform_wallet_for_identity( + &self, + identity: &crate::model::qualified_identity::QualifiedIdentity, + ) -> Result, TaskError> { + let (seed_hash, _) = identity + .determine_wallet_info() + .map_err(|e| { + tracing::error!("Failed to determine wallet info: {}", e); + TaskError::WalletNotFound + })? + .ok_or(TaskError::WalletNotFound)?; + self.require_platform_wallet(&seed_hash) + } + + /// Reset SPV filter_committed_height to force a rescan from birth_height. + /// + /// Call before `start_spv()` when wallet state isn't persisted yet. + pub async fn reset_spv_filter_committed_height(&self) { + self.wallet_manager.spv().reset_filter_committed_height().await; + } + pub fn start_spv(self: &Arc) -> Result<(), TaskError> { - // Skip if SPV is already active — avoids orphaned listener tasks from - // re-registering channels while existing handlers still hold old senders. - if self.spv_manager.status().status.is_active() { + // Skip if SPV is already running. + if self.wallet_manager.spv().is_started() { + tracing::info!("start_spv: SPV already started, skipping"); return Ok(()); } - // Count wallets that will be loaded into SPV (open wallets with accessible seeds). - // This is read synchronously so the SPV thread can wait for exactly this many. - let expected_wallets = self - .wallets - .read() - .map(|guard| { - guard - .values() - .filter(|w| { - w.read() - .ok() - .is_some_and(|g| g.is_open() && g.seed_bytes().is_ok()) - }) - .count() - }) - .unwrap_or(0); - // Register reconcile channel BEFORE starting SPV so the event handlers - // (spawned inside run_spv_loop) always capture a valid sender. - self.spv_setup_reconcile_listener(); - self.spv_setup_finality_listener(); - self.spv_manager - .start(expected_wallets) - .map_err(|e| TaskError::SpvStartFailed { detail: e })?; - // Immediately reflect the new SPV status in ConnectionStatus so the - // UI sees the change on the next frame instead of waiting for the - // next throttled trigger_refresh() cycle (2-10 seconds). + tracing::info!("start_spv: building SPV config..."); + let config = self + .build_spv_config() + .map_err(|e| { + tracing::error!("start_spv: failed to build config: {}", e); + TaskError::SpvStartFailed { detail: e } + })?; + tracing::info!("start_spv: config built, starting SPV..."); + + // Spawn the SpvEventBridge run-loop using the existing bridge + // (created in AppContext::new). Each start subscribes to the wallet + // manager's event channel; when SPV stops the broadcast channel + // closes and the run-loop exits naturally. + let event_rx = self.wallet_manager.subscribe_events(); + let bridge = Arc::clone(&self.spv_event_bridge); + self.subtasks.spawn_sync("spv_event_bridge", async move { + bridge.run(event_rx).await; + }); + + // Wire up the reconcile listener (debounces reconcile signals from + // the event bridge and writes wallet state back to DET). + // The reconcile channel is created fresh on each start so stale + // signals from a previous session don't leak. + let reconcile_rx = self.spv_event_bridge.new_reconcile_channel(); + self.spv_setup_reconcile_listener(reconcile_rx); + + // Spawn SPV sync via PlatformWalletManager's SpvRuntime. + let cancel = self.subtasks.cancellation_token.child_token(); + // Store the cancel token so stop_spv() can cancel it. + if let Ok(mut guard) = self.spv_cancel_token.lock() { + *guard = Some(cancel.clone()); + } + let wm = Arc::clone(&self.wallet_manager); + let conn_status = Arc::clone(&self.connection_status); + self.subtasks.spawn_sync("spv_main_loop", async move { + if let Err(e) = wm.spv().run(config, cancel).await { + tracing::error!(error = %e, "SPV runtime failed"); + conn_status.set_spv_last_error(Some(e.to_string())); + conn_status.set_spv_status(crate::spv::SpvStatus::Error); + conn_status.refresh_state(); + } + }); + + // Immediately reflect SPV Starting in ConnectionStatus. self.connection_status - .set_spv_status(self.spv_manager.status().status); + .set_spv_status(crate::spv::SpvStatus::Starting); self.connection_status.refresh_state(); + Ok(()) } @@ -94,22 +215,9 @@ impl AppContext { self: &Arc, wallet: Wallet, ) -> Result<(WalletSeedHash, Arc>), TaskError> { - // 1. Persist wallet and known addresses atomically - let addresses: Vec<_> = wallet - .known_addresses - .iter() - .map(|(address, path)| { - ( - address, - path, - DerivationPathReference::BIP44, - DerivationPathType::CLEAR_FUNDS, - ) - }) - .collect(); - + // 1. Persist wallet (no legacy address maps) self.db - .store_wallet_with_addresses(&wallet, &self.network, &addresses) + .store_wallet_with_addresses(&wallet, &self.network, &[]) .map_err(|e| { if is_unique_constraint_violation(&e) { TaskError::WalletAlreadyImported @@ -127,37 +235,27 @@ impl AppContext { self.has_wallet.store(true, Ordering::Relaxed); drop(wallets); - // 3. Bootstrap any additional addresses and load into SPV - self.bootstrap_wallet_addresses(&wallet_arc); - if self.core_backend_mode() == CoreBackendMode::Spv { - self.handle_wallet_unlocked(&wallet_arc); - } + // 3. Create PlatformWallet and load into SPV + self.handle_wallet_unlocked(&wallet_arc); Ok((seed_hash, wallet_arc)) } - pub fn bootstrap_wallet_addresses(&self, wallet: &Arc>) { - 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). - // INTENTIONAL(CODE-006): Bootstrap checks only PlatformPayment address type. - // Other platform address types may trigger redundant re-derivation, but - // bootstrap_known_addresses() is idempotent so this is safe. - 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); - } - } + pub fn bootstrap_wallet_addresses(&self, _wallet: &Arc>) { + // No-op: PlatformWallet's ManagedWalletInfo has all account types + // with address pools from the seed. Locked wallets show no addresses + // (address visibility requires authentication). } pub fn handle_wallet_unlocked(self: &Arc, wallet: &Arc>) { if let Some((seed_hash, seed_bytes)) = Self::wallet_seed_snapshot(wallet) { - self.queue_spv_wallet_load(seed_hash, seed_bytes); + // Register with the PlatformWalletManager (creates PlatformWallet + // and wires SPV event channel so IS-lock/ChainLock events reach + // AssetLockManager). Wallet loading into SPV is handled by the + // SpvRuntime's wallet adapter automatically. + self.register_with_platform_wallet_manager(seed_hash, seed_bytes); + // WalletManager bumps its own structural revision when wallets + // are added/removed, so no external notify_wallets_changed() is needed. // Note: Platform address sync is not done here. // Core UTXO refresh is handled at startup in bootstrap_loaded_wallets. @@ -184,6 +282,72 @@ impl AppContext { } } + /// Register an open wallet with the `PlatformWalletManager` and populate + /// the `wallet_id_mapping`. + /// + /// This bridges the old `Wallet` type to the new `PlatformWallet` type + /// during migration. If the wallet is already registered (e.g. from a + /// previous unlock), this is a no-op. + pub(crate) fn register_with_platform_wallet_manager( + self: &Arc, + seed_hash: WalletSeedHash, + seed_bytes: [u8; 64], + ) { + // Check if already registered + if let Ok(mapping) = self.wallet_id_mapping.lock() + && mapping.wallet_id_for_seed(&seed_hash).is_some() + { + return; + } + + let kw_network = self.wallet_network_key(); + + // Create a PlatformWallet via the manager — this wires the shared + // SPV event channel so IS-lock/ChainLock events reach AssetLockManager. + // The manager also initializes persisted state and registers the wallet + // for SPV processing in one call. + match tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on( + self.wallet_manager.create_wallet_from_seed_bytes( + kw_network, + seed_bytes, + Default::default(), + ), + ) + }) + { + Ok(platform_wallet) => { + let wallet_id = platform_wallet.wallet_id(); + + if let Ok(mut mapping) = self.wallet_id_mapping.lock() { + mapping.insert(seed_hash, wallet_id); + } + + // Store on the Wallet struct itself + if let Ok(wallets) = self.wallets.read() { + if let Some(wallet_arc) = wallets.get(&seed_hash) { + if let Ok(mut wallet) = wallet_arc.write() { + wallet.platform_wallet = Some(Arc::clone(&platform_wallet)); + } + } + } + + tracing::info!( + seed = %hex::encode(seed_hash), + wallet_id = %hex::encode(wallet_id), + "Registered wallet with PlatformWallet bridge" + ); + } + Err(e) => { + tracing::warn!( + seed = %hex::encode(seed_hash), + error = %e, + "Failed to create PlatformWallet from seed bytes for bridge" + ); + } + } + } + pub fn handle_wallet_locked(self: &Arc, wallet: &Arc>) { let seed_hash = match wallet.read() { Ok(guard) => guard.seed_hash(), @@ -192,7 +356,15 @@ impl AppContext { return; } }; - self.queue_spv_wallet_unload(seed_hash); + + // Clear platform wallet from the Wallet struct + if let Ok(mut guard) = wallet.write() { + guard.platform_wallet = None; + } + + if let Ok(mut mapping) = self.wallet_id_mapping.lock() { + mapping.remove_by_seed_hash(&seed_hash); + } } /// Queue async SyncNotes -> CheckNullifiers for an already-initialized @@ -254,23 +426,9 @@ impl AppContext { Some((guard.seed_hash(), seed_bytes)) } - fn queue_spv_wallet_load(self: &Arc, seed_hash: WalletSeedHash, seed_bytes: [u8; 64]) { - let spv = Arc::clone(&self.spv_manager); - self.subtasks.spawn_sync("spv_wallet_load", async move { - if let Err(error) = spv.load_wallet_from_seed(seed_hash, seed_bytes).await { - tracing::error!(seed = %hex::encode(seed_hash), %error, "Failed to load SPV wallet from seed"); - } - }); - } - - fn queue_spv_wallet_unload(self: &Arc, seed_hash: WalletSeedHash) { - let spv = Arc::clone(&self.spv_manager); - self.subtasks.spawn_sync("spv_wallet_unload", async move { - if let Err(error) = spv.unload_wallet(seed_hash).await { - tracing::error!(seed = %hex::encode(seed_hash), %error, "Failed to unload SPV wallet"); - } - }); - } + // queue_spv_wallet_load and queue_spv_wallet_unload removed — + // wallet registration is handled by PlatformWalletManager, and + // SpvRuntime's WalletAdapter reads from the shared wallets map. /// Queue automatic discovery of identities derived from a wallet. /// Checks identity indices 0 through max_identity_index for existing identities on the network. @@ -302,10 +460,21 @@ impl AppContext { }; for wallet in wallets.iter() { - self.bootstrap_wallet_addresses(wallet); + // Create PlatformWallet first — it populates ManagedWalletInfo with + // all account types and address pools from the seed. Then bootstrap + // only if PlatformWallet wasn't created (locked wallet). self.handle_wallet_unlocked(wallet); + self.bootstrap_wallet_addresses(wallet); } + // Sync all DB identities to platform-wallet IdentityManagers so they + // are available for DashPay and other platform-wallet operations. + self.sync_all_identities_to_platform_wallets(); + + // Register DashPay contact accounts in ManagedWalletInfo so SPV + // monitors incoming payment addresses for established contacts. + self.bootstrap_dashpay_contact_accounts(); + // Auto-refresh UTXOs from Core on startup so balances are current // without requiring the user to manually click Refresh (fixes GH#522). // Only in RPC mode — SPV mode handles UTXO loading via reconciliation. @@ -359,31 +528,26 @@ impl AppContext { } } - /// Update wallet platform address info from SDK-returned AddressInfos. + /// Update platform address info in DB from SDK-returned AddressInfos. /// This uses the proof-verified data from SDK operations rather than fetching. pub(crate) fn update_wallet_platform_address_info_from_sdk( &self, seed_hash: WalletSeedHash, address_infos: &dash_sdk::query_types::AddressInfos, ) -> Result<(), TaskError> { - let wallet_arc = { + // Verify the wallet exists + { let wallets = self.wallets.read()?; - wallets - .get(&seed_hash) - .cloned() - .ok_or(TaskError::WalletNotFound)? - }; - - let mut wallet = wallet_arc.write()?; + if !wallets.contains_key(&seed_hash) { + return Err(TaskError::WalletNotFound); + } + } for (platform_addr, maybe_info) in address_infos.iter() { if let Some(info) = maybe_info { // Convert PlatformAddress to core Address using the network let core_addr = platform_addr.to_address_with_network(self.network); - // Update in-memory wallet state - wallet.set_platform_address_info(core_addr.clone(), info.balance, info.nonce); - // Update database if let Err(e) = self.db.set_platform_address_info( &seed_hash, @@ -407,7 +571,7 @@ impl AppContext { Ok(()) } - pub(crate) fn register_spv_address( + pub(crate) async fn register_spv_address( &self, wallet: &Arc>, address: Address, @@ -415,16 +579,27 @@ impl AppContext { path_type: DerivationPathType, path_reference: DerivationPathReference, ) -> Result { - let mut guard = wallet.write()?; - if guard.known_addresses.contains_key(&address) { - return Ok(false); + // Extract what we need from the wallet under a short-lived sync lock, + // then drop the guard before any async work. + let (platform_wallet, seed_hash) = { + let guard = wallet.read()?; + (guard.platform_wallet.clone(), guard.seed_hash()) + }; + + // Check address ownership via PlatformWallet's async state lock. + if let Some(pw) = &platform_wallet { + let info = pw.state().await; + let has_it = crate::platform_wallet_bridge::CoreAddressInfo::all_from_wallet_info(info.managed_state.wallet_info()) + .iter() + .any(|a| a.address == address); + if has_it { + return Ok(false); + } } let (path_reference, path_type) = self.classify_derivation_metadata(&derivation_path, path_reference, path_type); - let seed_hash = guard.seed_hash(); - self.db.add_address_if_not_exists( &seed_hash, &address, @@ -435,18 +610,6 @@ impl AppContext { None, )?; - guard - .known_addresses - .insert(address.clone(), derivation_path.clone()); - guard.watched_addresses.insert( - derivation_path, - WalletAddressInfo { - address, - path_type, - path_reference, - }, - ); - Ok(true) } @@ -466,7 +629,7 @@ impl AppContext { } } - fn sync_spv_account_addresses( + async fn sync_spv_account_addresses( &self, wallet_info: &ManagedWalletInfo, wallet_arc: &Arc>, @@ -489,7 +652,7 @@ impl AppContext { info.path.clone(), path_type, path_reference, - ) + ).await { inserted += 1; } @@ -580,106 +743,9 @@ impl AppContext { (default_ref, default_type) } - /// Listen for SPV instant lock / chain lock events and populate - /// transactions_waiting_for_finality so identity registration can proceed. - pub fn spv_setup_finality_listener(self: &Arc) { - let rx = self.spv_manager.register_finality_channel(); - let ctx = Arc::clone(self); - let cancel = self.subtasks.cancellation_token.clone(); - self.subtasks - .spawn_sync("spv_finality_listener", async move { - tokio::pin!(rx); - loop { - tokio::select! { - _ = cancel.cancelled() => break, - maybe = rx.recv() => { - let Some(event) = maybe else { break; }; - // Wrap handler in select so cancellation can interrupt - // even when blocked on locks held by the SPV sync thread. - tokio::select! { - _ = cancel.cancelled() => break, - result = ctx.handle_spv_finality_event(event) => { - if let Err(e) = result { - tracing::debug!("SPV finality event error: {}", e); - } - } - } - } - } - } - }); - } - - async fn handle_spv_finality_event( - &self, - event: AssetLockFinalityEvent, - ) -> Result<(), TaskError> { - match event { - AssetLockFinalityEvent::InstantLock { txid, instant_lock } => { - // Check if this txid is pending in transactions_waiting_for_finality - let is_pending = { - let transactions = self.transactions_waiting_for_finality.lock()?; - matches!(transactions.get(&txid), Some(None)) - }; - if !is_pending { - return Ok(()); - } - - // Retrieve the full transaction from the database - let (tx, ..) = self - .db - .get_asset_lock_transaction(txid.as_byte_array())? - .ok_or(TaskError::AssetLockTransactionNotFoundInDatabase)?; - - self.received_asset_lock_finality(&tx, Some(*instant_lock), None)?; - } - AssetLockFinalityEvent::ChainLock { - height: _height, .. - } => { - // Get all pending txids (where proof is None) - let pending_txids: Vec = { - let transactions = self.transactions_waiting_for_finality.lock()?; - transactions - .iter() - .filter_map( - |(txid, proof)| if proof.is_none() { Some(*txid) } else { None }, - ) - .collect() - }; - if pending_txids.is_empty() { - return Ok(()); - } - - let sdk = self.sdk.load().as_ref().clone(); - - for txid in pending_txids { - match get_transaction_info(&sdk, &txid).await { - Ok(tx_info) if tx_info.is_chain_locked && tx_info.height > 0 => { - if let Ok(Some((tx, ..))) = - self.db.get_asset_lock_transaction(txid.as_byte_array()) - { - let _ = self.received_asset_lock_finality( - &tx, - None, - Some(tx_info.height), - ); - } - } - _ => { - // Transaction not yet chain-locked at this height, or DAPI - // lookup failed — will retry on next chain lock event. - } - } - } - } - } - Ok(()) - } - /// Subscribe to SPV reconcile signals and debounce updates. - pub fn spv_setup_reconcile_listener(self: &Arc) { + fn spv_setup_reconcile_listener(self: &Arc, rx: tokio::sync::mpsc::Receiver<()>) { use tokio::time::{Duration, Instant, sleep}; - let rx = self.spv_manager.register_reconcile_channel(); let ctx = Arc::clone(self); let cancel = self.subtasks.cancellation_token.clone(); self.subtasks.spawn_sync("spv_reconcile_listener", async move { @@ -721,258 +787,561 @@ impl AppContext { } /// Reconcile SPV wallet state into DET. + /// + /// Reads from `PlatformWallet` instances (which share the same wallet + /// data as the SPV adapter through `Arc`) instead of the old + /// `PlatformWallet::core()`. pub async fn reconcile_spv_wallets(&self) -> Result<(), TaskError> { - let wm_arc = self.spv_manager.wallet(); - let wm = wm_arc.read().await; - let mapping = self.spv_manager.det_wallets_snapshot(); - - // Take a snapshot of known addresses per wallet so we can scope DB updates - let wallets_guard = self.wallets.read()?; - - for (seed_hash, wallet_id) in mapping.iter() { - // Log total balance for visibility - let balance = wm - .get_wallet_balance(wallet_id) - .map_err(|e| crate::spv::SpvError::WalletError(e.to_string()))?; - tracing::debug!(wallet = %hex::encode(seed_hash), spendable = balance.spendable(), unconfirmed = balance.unconfirmed(), total = balance.total(), "SPV balance snapshot"); + // Snapshot the wallets map under a short sync lock, then drop. + let wallet_entries: Vec<_> = { + let wallets_guard = self.wallets.read()?; + wallets_guard + .iter() + .map(|(k, v)| (*k, Arc::clone(v))) + .collect() + }; - let Some(wallet_info) = wm.get_wallet_info(wallet_id) else { + for (seed_hash, wallet_arc) in &wallet_entries { + let Some(pw) = self.get_platform_wallet(seed_hash) else { continue; }; - let Some(wallet_arc) = wallets_guard.get(seed_hash).cloned() else { + // Lock-free balance read — no lock required. + let balance = pw.core().balance(); + tracing::debug!(wallet = %hex::encode(seed_hash), spendable = balance.spendable(), unconfirmed = balance.unconfirmed(), total = balance.total(), "SPV balance snapshot"); + + let Some(wallet_info) = pw.try_state() else { continue; }; - self.sync_spv_account_addresses(wallet_info, &wallet_arc); + self.sync_spv_account_addresses(wallet_info.managed_state.wallet_info(), wallet_arc).await; + + // Wallet balances, UTXOs, and transactions are now persisted via + // the changeset path (auto-flushed via FlushStrategy::Immediate). + // No direct DB writes for these are needed here. + + // Get the wallet's addresses from PlatformWallet to register unknown + // SPV addresses in the DET address table (app-level metadata). + // Uses async state() to avoid blocking_read panic in async context. + let mut wallet_addresses: std::collections::BTreeSet
= { + let info = pw.state().await; + crate::platform_wallet_bridge::CoreAddressInfo::all_from_wallet_info(info.managed_state.wallet_info()) + .into_iter() + .map(|a| a.address) + .collect() + }; - if let Ok(mut wallet) = wallet_arc.write() { - wallet.update_spv_balances( - balance.spendable(), - balance.unconfirmed(), - balance.total(), - ); - // Persist balances to database - if let Err(e) = self.db.update_wallet_balances( - seed_hash, - balance.spendable(), - balance.unconfirmed(), - balance.total(), - ) { - tracing::warn!(wallet = %hex::encode(seed_hash), error = %e, "Failed to persist wallet balances"); + // Register unknown addresses found in SPV account metadata + let collection = wallet_info.managed_state.wallet_info().accounts(); + for acc in collection.all_accounts() { + let account_type = acc.account_type.to_account_type(); + for address in acc.account_type.all_addresses() { + if wallet_addresses.contains(&address) { + continue; + } + if let Some(ai) = acc.get_address_info(&address) { + let (path_reference, path_type) = Self::spv_account_metadata(&account_type) + .unwrap_or_else(|| { + let default_ref = if ai.path.is_bip44(self.network) { + DerivationPathReference::BIP44 + } else if ai.path.is_bip32() { + DerivationPathReference::BIP32 + } else { + tracing::warn!( + path = %ai.path, + "SPV address has unrecognized derivation path structure" + ); + DerivationPathReference::Unknown + }; + (default_ref, DerivationPathType::CLEAR_FUNDS) + }); + + if let Ok(true) = self.register_spv_address( + wallet_arc, + address.clone(), + ai.path.clone(), + path_type, + path_reference, + ).await { + wallet_addresses.insert(address.clone()); + } + } } } - // Get the wallet's known addresses (only update those to avoid cross-wallet churn) - let mut known_addresses: std::collections::BTreeSet
= { - let w = wallet_arc.read()?; - w.known_addresses.keys().cloned().collect() - }; + tracing::info!( + wallet = %hex::encode(seed_hash), + spv_spendable = balance.spendable(), + spv_total = balance.total(), + "SPV reconcile summary" + ); - // Clear existing UTXOs for these addresses in this network - for addr in &known_addresses { - let _ = self.db.execute( - "DELETE FROM utxos WHERE address = ? AND network = ?", - rusqlite::params![addr.to_string(), self.network.to_string()], + // Persistence is handled automatically by the SPV adapter's + // queue_persist() calls — the SqliteWalletPersister auto-flushes + // each queued changeset when FlushStrategy::Immediate is active. + } + + Ok(()) + } + + // ----------------------------------------------------------------- + // Platform-wallet IdentityManager synchronization + // ----------------------------------------------------------------- + + /// Sync a `QualifiedIdentity` to the platform-wallet's `IdentityManager`. + /// + /// Called after every insert/update so that the in-memory IdentityManager + /// stays in sync with the SQLite database. This is best-effort: if the + /// platform wallet is not available (e.g. external import, wallet locked) + /// or the lock cannot be acquired, the error is logged and the caller + /// is not affected. + pub(crate) fn sync_identity_to_platform_wallet(&self, qualified_identity: &QualifiedIdentity) { + // 1. Resolve the platform wallet for this identity + let (seed_hash, _wallet_index) = match qualified_identity.determine_wallet_info() { + Ok(Some(info)) => info, + Ok(None) => { + // No wallet association — external import or no derivation path + tracing::trace!( + identity = %qualified_identity.identity.id(), + "Skipping platform-wallet sync: no wallet association" + ); + return; + } + Err(e) => { + tracing::debug!( + identity = %qualified_identity.identity.id(), + error = %e, + "Skipping platform-wallet sync: cannot determine wallet info" ); + return; } + }; - // Read current UTXOs from SPV and re-insert, registering unknown addresses if derivation metadata is available - let utxos = wm - .wallet_utxos(wallet_id) - .map_err(|e| crate::spv::SpvError::WalletError(e.to_string()))?; - - let mut per_address_sum: std::collections::BTreeMap = Default::default(); - // Build in-memory UTXO map to update wallet model - let mut new_utxos: std::collections::HashMap< - Address, - std::collections::HashMap< - dash_sdk::dpp::dashcore::OutPoint, - dash_sdk::dpp::dashcore::TxOut, - >, - > = Default::default(); - - for u in utxos { - let outpoint = u.outpoint; - let tx_out = u.txout.clone(); - - // Derive address from script - let address = match Address::from_script(&tx_out.script_pubkey, self.network) { - Ok(a) => a, - Err(_) => continue, - }; + let platform_wallet = match self.get_platform_wallet(&seed_hash) { + Some(pw) => pw, + None => { + tracing::trace!( + identity = %qualified_identity.identity.id(), + seed = %hex::encode(seed_hash), + "Skipping platform-wallet sync: platform wallet not registered" + ); + return; + } + }; - // Always track the UTXO in the in-memory map for correct balance calculation - new_utxos - .entry(address.clone()) - .or_default() - .insert(outpoint, tx_out.clone()); - - // Always count the UTXO value in per-address sum - *per_address_sum.entry(address.clone()).or_default() += tx_out.value; - - // If address unknown to DET, try to register using SPV metadata - if !known_addresses.contains(&address) { - let collection = wallet_info.accounts(); - let mut registered = false; - for acc in collection.all_accounts() { - if let Some(ai) = acc.get_address_info(&address) { - let account_type = acc.account_type.to_account_type(); - let (path_reference, path_type) = - Self::spv_account_metadata(&account_type).unwrap_or_else(|| { - let default_ref = if ai.path.is_bip44(self.network) { - DerivationPathReference::BIP44 - } else if ai.path.is_bip32() { - DerivationPathReference::BIP32 - } else { - tracing::warn!( - path = %ai.path, - "SPV address has unrecognized derivation path structure" - ); - DerivationPathReference::Unknown - }; - (default_ref, DerivationPathType::CLEAR_FUNDS) - }); - - if let Ok(inserted) = self.register_spv_address( - &wallet_arc, - address.clone(), - ai.path.clone(), - path_type, - path_reference, - ) { - if inserted { - known_addresses.insert(address.clone()); - } - registered = true; - } - break; + // 2. Access the identity_manager (tokio RwLock, use try_write) + let identity_wallet = platform_wallet.identity(); + let mut manager = match identity_wallet.try_state_mut() { + Some(guard) => guard, + None => { + tracing::debug!( + identity = %qualified_identity.identity.id(), + "Skipping platform-wallet sync: identity_manager lock contended" + ); + return; + } + }; + + let identity_id = qualified_identity.identity.id(); + let identity_index = qualified_identity.wallet_index.unwrap_or(0); + + // 3. Convert QualifiedIdentity data for the ManagedIdentity + let mi_key_storage = + Self::convert_key_storage(&qualified_identity.private_keys, &seed_hash); + let mi_dpns_names = Self::convert_dpns_names(&qualified_identity.dpns_names); + let mi_status = Self::convert_identity_status(qualified_identity.status); + + // 4. Add or update the identity in the manager + if let Some(managed) = manager.identity_manager.managed_identity_mut(&identity_id) { + // Update existing managed identity + managed.identity = qualified_identity.identity.clone(); + managed.key_storage = mi_key_storage; + managed.dpns_names = mi_dpns_names; + managed.status = mi_status; + managed.wallet_seed_hash = Some(seed_hash); + managed.top_ups = qualified_identity.top_ups.clone(); + if let Some(alias) = &qualified_identity.alias { + managed.label = Some(alias.clone()); + } + tracing::debug!( + identity = %identity_id, + "Updated identity in platform-wallet IdentityManager" + ); + } else { + // Add new identity + match manager.identity_manager.add_identity(qualified_identity.identity.clone(), identity_index) { + Ok(()) => { + // Now set extra fields on the newly added managed identity + if let Some(managed) = manager.identity_manager.managed_identity_mut(&identity_id) { + managed.key_storage = mi_key_storage; + managed.dpns_names = mi_dpns_names; + managed.status = mi_status; + managed.wallet_seed_hash = Some(seed_hash); + managed.top_ups = qualified_identity.top_ups.clone(); + if let Some(alias) = &qualified_identity.alias { + managed.label = Some(alias.clone()); } } - if !registered { - tracing::debug!( - wallet = %hex::encode(seed_hash), - address = %address, - value = tx_out.value, - "SPV UTXO address not registered in DET (counted in balance but not in address table)" - ); - // Still persist the UTXO to DB and delete stale entry first - let _ = self.db.execute( - "DELETE FROM utxos WHERE address = ? AND network = ?", - rusqlite::params![address.to_string(), self.network.to_string()], - ); - } + tracing::debug!( + identity = %identity_id, + index = identity_index, + "Added identity to platform-wallet IdentityManager" + ); + } + Err(e) => { + tracing::debug!( + identity = %identity_id, + error = %e, + "Failed to add identity to platform-wallet IdentityManager" + ); } + } + } + } - // Insert UTXO row into DB - self.db.insert_utxo( - outpoint.txid.as_ref(), - outpoint.vout, - &address, - tx_out.value, - &tx_out.script_pubkey.to_bytes(), - self.network, - )?; - } - - // Write per-address balances and UTXOs into wallet model - if let Some(wref) = wallets_guard.get(seed_hash) - && let Ok(mut w) = wref.write() - { - // 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"); - } + /// Sync all locally stored identities to platform-wallet IdentityManagers. + /// + /// Called during wallet bootstrap so identities loaded from the database + /// are available to DashPay and other platform-wallet operations. + pub fn sync_all_identities_to_platform_wallets(&self) { + match self.load_local_qualified_identities() { + Ok(identities) => { + let count = identities.len(); + for identity in &identities { + self.sync_identity_to_platform_wallet(identity); + } + tracing::info!( + count, + "Synced local identities to platform-wallet IdentityManagers" + ); + } + Err(e) => { + tracing::warn!( + error = %e, + "Failed to load local identities for platform-wallet sync" + ); + } + } + } + + /// Register DashPay contact accounts in ManagedWalletInfo for all + /// established contacts loaded from the database. + /// + /// Called during wallet bootstrap (after identities are synced) so that + /// SPV monitors incoming payment addresses for existing contacts. + fn bootstrap_dashpay_contact_accounts(&self) { + let network_str = self.network.to_string(); + + // Load all identities to find their contacts + let identities = match self.load_local_qualified_identities() { + Ok(ids) => ids, + Err(e) => { + tracing::debug!(error = %e, "Skipping contact account bootstrap"); + return; + } + }; + + let mut registered = 0u32; + + for identity in &identities { + let identity_id = identity.identity.id(); + + // Load contacts for this identity from DB + let contacts = match self.db.load_dashpay_contacts(&identity_id, &network_str) { + Ok(c) => c, + Err(e) => { + tracing::debug!(identity = %identity_id, error = %e, "Failed to load contacts"); + continue; } + }; - for (addr, sum) in per_address_sum.into_iter() { - if let Err(e) = w.update_address_balance(&addr, sum, self) { - tracing::debug!(address = %addr, error = %e, "Failed to update address balance"); + // Find the PlatformWallet for this identity's wallet + let (seed_hash, _) = match identity.determine_wallet_info() { + Ok(Some(info)) => info, + _ => continue, + }; + let pw = match self.get_platform_wallet(&seed_hash) { + Some(pw) => pw, + None => continue, + }; + + for contact in &contacts { + if contact.contact_status != "accepted" { + continue; + } + + let contact_id = match dash_sdk::dpp::prelude::Identifier::from_bytes( + &contact.contact_identity_id, + ) { + Ok(id) => id, + Err(_) => continue, + }; + + // Register the contact account synchronously using blocking locks. + // This creates a DashpayReceivingFunds account in ManagedWalletInfo + // so SPV monitors incoming payment addresses for this contact. + let account_type = AccountType::DashpayReceivingFunds { + index: 0, + user_identity_id: identity_id.to_buffer(), + friend_identity_id: contact_id.to_buffer(), + }; + + use dash_sdk::dpp::key_wallet::Account; + use dash_sdk::dpp::key_wallet::managed_account::ManagedCoreAccount; + + let kw_network = self.wallet_network_key(); + + // Derive xpub and add account to key-wallet's Wallet (key store), + // then add managed wrapper to ManagedWalletInfo (address pools). + // Both live inside the single PlatformWalletInfo guard. + if let Some(mut info_guard) = pw.try_state_mut() { + let path = match account_type.derivation_path(kw_network) { + Ok(p) => p, + Err(e) => { + tracing::debug!(contact = %contact_id, error = %e, "Failed to derive contact path"); + continue; + } + }; + let account_xpub = match info_guard.managed_state.wallet().derive_extended_public_key(&path) { + Ok(xpub) => xpub, + Err(e) => { + tracing::debug!(contact = %contact_id, error = %e, "Failed to derive contact xpub"); + continue; + } + }; + let wallet_id = info_guard.managed_state.wallet().wallet_id; + let account = Account { + parent_wallet_id: Some(wallet_id), + account_type, + network: kw_network, + account_xpub, + is_watch_only: false, + }; + let _ = info_guard.managed_state.wallet_mut().accounts.insert(account.clone()); + + let managed = ManagedCoreAccount::from_account(&account); + if let Err(e) = info_guard.managed_state.wallet_info_mut().accounts.insert(managed) { + tracing::debug!(contact = %contact_id, error = %e, "Failed to insert contact account"); + } else { + registered += 1; } } } + } - let history = wm - .wallet_transaction_history(wallet_id) - .map_err(|e| crate::spv::SpvError::WalletError(e.to_string()))?; - let wallet_transactions: Vec = history - .into_iter() - .map(|record| { - let status = TransactionStatus::from_height(record.height); - WalletTransaction { - txid: record.txid, - transaction: record.transaction.clone(), - timestamp: record.timestamp, - height: record.height, - block_hash: record.block_hash, - 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 - }, - status, - } - }) - .collect(); + if registered > 0 { + tracing::info!(count = registered, "Registered DashPay contact accounts"); + } + } - tracing::info!( - wallet = %hex::encode(seed_hash), - spv_transactions = wallet_transactions.len(), - spv_spendable = balance.spendable(), - spv_total = balance.total(), - "SPV reconcile summary" + /// Convert QualifiedIdentity's `KeyStorage` to ManagedIdentity's `KeyStorage`. + /// + /// Only keys with `PrivateKeyTarget::PrivateKeyOnMainIdentity` are converted. + /// Voter/operator keys and encrypted keys are skipped. + fn convert_key_storage( + qi_keys: &crate::model::qualified_identity::encrypted_key_storage::KeyStorage, + _seed_hash: &WalletSeedHash, + ) -> ManagedKeyStorage { + let mut result = ManagedKeyStorage::new(); + + for ((target, key_id), (qualified_pub_key, private_key_data)) in qi_keys.private_keys.iter() + { + // Only convert main identity keys + if *target != PrivateKeyTarget::PrivateKeyOnMainIdentity { + continue; + } + + let mi_private_key = match private_key_data { + QIPrivateKeyData::Clear(bytes) | QIPrivateKeyData::AlwaysClear(bytes) => { + ManagedPrivateKeyData::Clear(Zeroizing::new(*bytes)) + } + QIPrivateKeyData::AtWalletDerivationPath(WalletDerivationPath { + wallet_seed_hash, + derivation_path, + }) => ManagedPrivateKeyData::AtWalletDerivationPath { + wallet_seed_hash: *wallet_seed_hash, + derivation_path: derivation_path.clone(), + }, + QIPrivateKeyData::Encrypted(_) => { + // Cannot use encrypted keys without a password; skip + continue; + } + }; + + result.insert( + *key_id, + ( + qualified_pub_key.identity_public_key.clone(), + mi_private_key, + ), ); + } - // Only replace transactions if SPV returned some, to avoid wiping - // previously persisted history when SPV hasn't populated history yet. - if !wallet_transactions.is_empty() { - self.db.replace_wallet_transactions( - seed_hash, - &self.network, - &wallet_transactions, - )?; - } + result + } - if let Some(wref) = wallets_guard.get(seed_hash) - && let Ok(mut wallet) = wref.write() - && !wallet_transactions.is_empty() - { - wallet.set_transactions(wallet_transactions); + /// Convert QualifiedIdentity's `DPNSNameInfo` vec to ManagedIdentity's `DpnsNameInfo` vec. + fn convert_dpns_names( + qi_names: &[crate::model::qualified_identity::DPNSNameInfo], + ) -> Vec { + qi_names + .iter() + .map(|n| ManagedDpnsNameInfo { + label: n.name.clone(), + acquired_at: Some(n.acquired_at), + }) + .collect() + } + + /// Convert QualifiedIdentity's `IdentityStatus` to ManagedIdentity's `IdentityStatus`. + fn convert_identity_status( + qi_status: crate::model::qualified_identity::IdentityStatus, + ) -> ManagedIdentityStatus { + match qi_status { + crate::model::qualified_identity::IdentityStatus::Unknown => { + ManagedIdentityStatus::Unknown + } + crate::model::qualified_identity::IdentityStatus::PendingCreation => { + ManagedIdentityStatus::PendingCreation + } + crate::model::qualified_identity::IdentityStatus::Active => { + ManagedIdentityStatus::Active + } + crate::model::qualified_identity::IdentityStatus::NotFound => { + ManagedIdentityStatus::NotFound + } + crate::model::qualified_identity::IdentityStatus::FailedCreation => { + ManagedIdentityStatus::FailedCreation } } - - Ok(()) } pub fn stop_spv(&self) { - self.spv_manager.stop(); + // Cancel the SPV cancel token. This triggers the SpvRuntime::run() + // future to exit (which stops the SPV client) and cascades to the + // event bridge and reconcile listener (they exit when their channels + // close or the global cancellation token fires). + if let Ok(mut guard) = self.spv_cancel_token.lock() { + if let Some(token) = guard.take() { + token.cancel(); + } + } + // Immediately reflect the new SPV status in ConnectionStatus so the // UI sees the change on the next frame instead of waiting for the // next throttled trigger_refresh() cycle (2-10 seconds). self.connection_status - .set_spv_status(self.spv_manager.status().status); + .set_spv_status(crate::spv::SpvStatus::Stopped); self.connection_status.refresh_state(); // Reset the throttle timer so trigger_refresh() starts polling // at 200ms intervals and picks up the Stopped transition quickly. self.connection_status.reset_timer(); } + + /// Build a [`ClientConfig`] for the SPV runtime. + /// + /// Mirrors the logic from the former `SpvManager::build_client()`. + fn build_spv_config(&self) -> Result { + use dash_sdk::dash_spv::ClientConfig; + use dash_sdk::dash_spv::client::config::MempoolStrategy; + use dash_sdk::dash_spv::types::ValidationMode; + + // Determine SPV data directory + let cfg = self.config.read().map_err(|e| e.to_string())?; + let data_dir = build_spv_data_dir(&self.data_dir, self.network, &cfg)?; + std::fs::create_dir_all(&data_dir) + .map_err(|e| format!("Failed to create SPV data dir: {e}"))?; + + // Check if there are open wallets + let has_wallets = self + .wallets + .read() + .map(|g| { + g.values() + .any(|w| w.read().ok().is_some_and(|g| g.is_open())) + }) + .unwrap_or(false); + + let start_height = if has_wallets { 0 } else { u32::MAX }; + + let mut config = ClientConfig::new(self.network) + .with_storage_path(data_dir) + .with_validation_mode(ValidationMode::Full) + .with_start_height(start_height) + .with_mempool_tracking(MempoolStrategy::BloomFilter); + + // Load user preference for local node + let use_local_node = self.db.get_use_local_spv_node().unwrap_or(false); + + // Configure peer discovery based on network type and user preference + if self.network == Network::Devnet || self.network == Network::Regtest { + if let Some(peer) = self.primary_peer_socket() { + config.add_peer(peer); + } + } else if use_local_node { + if let Some(peer) = self.primary_peer_socket() { + config.add_peer(peer); + } + } + // Otherwise, no peers added — SPV will use DNS seed discovery + + Ok(config) + } + + /// Resolve the primary peer socket address from config. + fn primary_peer_socket(&self) -> Option { + use std::net::ToSocketAddrs; + let config = self.config.read().ok()?; + let host = config.core_host.as_deref()?; + let port = match self.network { + Network::Mainnet => 9999, + Network::Testnet => 19999, + Network::Devnet => 20001, + Network::Regtest => 19899, + _ => 9999, + }; + let addr = format!("{}:{}", host, port); + addr.to_socket_addrs().ok()?.next() + } +} + +/// Build the SPV data directory path for the given network. +fn build_spv_data_dir( + app_data_dir: &std::path::Path, + network: Network, + config: &crate::config::NetworkConfig, +) -> Result { + let mut base = app_data_dir.to_path_buf(); + base.push("spv"); + std::fs::create_dir_all(&base).map_err(|e| format!("Failed to create SPV base dir: {e}"))?; + + let network_dir = match network { + Network::Mainnet => "mainnet".to_string(), + Network::Testnet => "testnet".to_string(), + Network::Devnet => { + let name = config + .devnet_name + .clone() + .unwrap_or_else(|| "devnet".to_string()); + let sanitized: String = name + .chars() + .map(|c| { + if c.is_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect(); + if sanitized.is_empty() { + "devnet".to_string() + } else { + sanitized + } + } + Network::Regtest => "regtest".to_string(), + other => format!("{other:?}"), + }; + + Ok(base.join(network_dir)) } diff --git a/src/context_provider_spv.rs b/src/context_provider_spv.rs index 1479368f4..80c715d45 100644 --- a/src/context_provider_spv.rs +++ b/src/context_provider_spv.rs @@ -10,7 +10,7 @@ use std::sync::{Arc, Mutex}; /// SPV-based ContextProvider for the Dash SDK. /// /// - DataContract and TokenConfiguration are served from the local DB (same as RPC provider) -/// - Quorum public keys are resolved via dash-spv (through SpvManager) when in SPV mode +/// - Quorum public keys are resolved via dash-spv (through PlatformWalletManager's SpvRuntime) when in SPV mode #[derive(Debug)] pub(crate) struct SpvProvider { db: Arc, @@ -100,11 +100,16 @@ impl ContextProvider for SpvProvider { .as_ref() .ok_or(ContextProviderError::Config("no app context".to_string()))?; - let spv_manager = app_ctx.spv_manager(); - - spv_manager - .get_quorum_public_key(quorum_type, quorum_hash, core_chain_locked_height) - .map_err(ContextProviderError::Generic) + // Use PlatformWalletManager's SpvRuntime for quorum key lookups. + let wm = &app_ctx.wallet_manager; + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + wm.spv() + .get_quorum_public_key(quorum_type, quorum_hash, core_chain_locked_height) + .await + .map_err(|e| ContextProviderError::Generic(e.to_string())) + }) + }) } fn get_platform_activation_height( diff --git a/src/database/asset_lock_transaction.rs b/src/database/asset_lock_transaction.rs index 4329ab400..7bae2b18a 100644 --- a/src/database/asset_lock_transaction.rs +++ b/src/database/asset_lock_transaction.rs @@ -25,9 +25,9 @@ impl Database { let conn = self.conn.lock().unwrap(); let sql = " - INSERT INTO asset_lock_transaction (tx_id, transaction_data, amount, instant_lock_data, wallet, network) - VALUES (?1, ?2, ?3, ?4, ?5, ?6) - ON CONFLICT(tx_id) DO UPDATE SET + INSERT INTO asset_lock_transaction (tx_id, output_index, transaction_data, amount, instant_lock_data, wallet, network) + VALUES (?1, 0, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT(tx_id, output_index) DO UPDATE SET transaction_data = excluded.transaction_data, amount = excluded.amount, instant_lock_data = COALESCE(excluded.instant_lock_data, asset_lock_transaction.instant_lock_data), @@ -50,16 +50,29 @@ impl Database { } /// Retrieves an asset lock transaction by its transaction ID. - #[allow(dead_code)] // May be used for querying asset locks + /// + /// Returns (Transaction, amount, Option, Option, + /// Option, wallet_seed_hash, network). #[allow(clippy::type_complexity)] pub fn get_asset_lock_transaction( &self, txid: &[u8; 32], - ) -> rusqlite::Result, [u8; 32], String)>> { + ) -> rusqlite::Result< + Option<( + Transaction, + u64, + Option, + Option, + Option>, + [u8; 32], + String, + )>, + > { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( - "SELECT transaction_data, amount, instant_lock_data, wallet, network FROM asset_lock_transaction WHERE tx_id = ?1", + "SELECT transaction_data, amount, instant_lock_data, chain_locked_height, identity_id, wallet, network + FROM asset_lock_transaction WHERE tx_id = ?1", )?; let mut rows = stmt.query(params![txid])?; @@ -68,8 +81,10 @@ impl Database { let tx_data: Vec = row.get(0)?; let amount: u64 = row.get(1)?; let islock_data: Option> = row.get(2)?; - let wallet_seed: Vec = row.get(3)?; - let network: String = row.get(4)?; + let chain_locked_height: Option = row.get(3)?; + let identity_id: Option> = row.get(4)?; + let wallet_seed: Vec = row.get(5)?; + let network: String = row.get(6)?; let tx: Transaction = deserialize(&tx_data).map_err(|_| rusqlite::Error::InvalidQuery)?; @@ -83,7 +98,15 @@ impl Database { .try_into() .map_err(|_| rusqlite::Error::InvalidQuery)?; - Ok(Some((tx, amount, islock, wallet_seed_hash, network))) + Ok(Some(( + tx, + amount, + islock, + chain_locked_height, + identity_id, + wallet_seed_hash, + network, + ))) } else { Ok(None) } @@ -255,6 +278,118 @@ impl Database { Ok(results) } + /// Retrieves all asset lock transactions for a specific wallet seed hash and network. + /// + /// Returns tuples of (Transaction, amount, Option, Option, + /// Option). + #[allow(clippy::type_complexity)] + pub fn get_asset_lock_transactions_for_wallet( + &self, + wallet_seed_hash: &[u8; 32], + network: Network, + ) -> rusqlite::Result< + Vec<( + Transaction, + u64, + Option, + Option, + Option>, + )>, + > { + let conn = self.conn.lock().unwrap(); + + let mut stmt = conn.prepare( + "SELECT transaction_data, amount, instant_lock_data, chain_locked_height, identity_id + FROM asset_lock_transaction + WHERE wallet = ?1 AND network = ?2", + )?; + + let mut rows = stmt.query(params![wallet_seed_hash, network.to_string()])?; + let mut results = Vec::new(); + + while let Some(row) = rows.next()? { + let tx_data: Vec = row.get(0)?; + let amount: u64 = row.get(1)?; + let islock_data: Option> = row.get(2)?; + let chain_locked_height: Option = row.get(3)?; + let identity_id: Option> = row.get(4)?; + + let tx: Transaction = + deserialize(&tx_data).map_err(|_| rusqlite::Error::InvalidQuery)?; + let islock = if let Some(islock_bytes) = islock_data { + Some(deserialize(&islock_bytes).map_err(|_| rusqlite::Error::InvalidQuery)?) + } else { + None + }; + + results.push((tx, amount, islock, chain_locked_height, identity_id)); + } + + Ok(results) + } + + /// Retrieves unused asset lock transactions for a specific wallet (where identity_id + /// IS NULL and identity_id_potentially_in_creation IS NULL). + #[allow(clippy::type_complexity)] + pub fn get_unused_asset_lock_transactions_for_wallet( + &self, + wallet_seed_hash: &[u8; 32], + network: Network, + ) -> rusqlite::Result, Option)>> { + let conn = self.conn.lock().unwrap(); + + let mut stmt = conn.prepare( + "SELECT transaction_data, amount, instant_lock_data, chain_locked_height + FROM asset_lock_transaction + WHERE wallet = ?1 AND network = ?2 + AND identity_id IS NULL + AND identity_id_potentially_in_creation IS NULL", + )?; + + let mut rows = stmt.query(params![wallet_seed_hash, network.to_string()])?; + let mut results = Vec::new(); + + while let Some(row) = rows.next()? { + let tx_data: Vec = row.get(0)?; + let amount: u64 = row.get(1)?; + let islock_data: Option> = row.get(2)?; + let chain_locked_height: Option = row.get(3)?; + + let tx: Transaction = + deserialize(&tx_data).map_err(|_| rusqlite::Error::InvalidQuery)?; + let islock = if let Some(islock_bytes) = islock_data { + Some(deserialize(&islock_bytes).map_err(|_| rusqlite::Error::InvalidQuery)?) + } else { + None + }; + + results.push((tx, amount, islock, chain_locked_height)); + } + + Ok(results) + } + + /// Checks whether there are any unused asset locks for a wallet (identity_id IS NULL + /// and identity_id_potentially_in_creation IS NULL). + pub fn has_unused_asset_lock_transactions( + &self, + wallet_seed_hash: &[u8; 32], + network: Network, + ) -> rusqlite::Result { + let conn = self.conn.lock().unwrap(); + + let count: u32 = conn.query_row( + "SELECT COUNT(*) FROM asset_lock_transaction + WHERE wallet = ?1 AND network = ?2 + AND identity_id IS NULL + AND identity_id_potentially_in_creation IS NULL", + params![wallet_seed_hash, network.to_string()], + |row| row.get(0), + )?; + + Ok(count > 0) + } + /// Retrieves asset lock transactions by identity ID. #[allow(dead_code)] // May be used for identity-specific transaction history #[allow(clippy::type_complexity)] @@ -356,7 +491,8 @@ impl Database { conn.execute( "CREATE TABLE asset_lock_transaction ( - tx_id BLOB PRIMARY KEY, + tx_id BLOB NOT NULL, + output_index INTEGER NOT NULL DEFAULT 0, transaction_data BLOB NOT NULL, amount INTEGER, instant_lock_data BLOB, @@ -365,6 +501,11 @@ impl Database { identity_id_potentially_in_creation BLOB, wallet BLOB NOT NULL, network TEXT NOT NULL, + account_index INTEGER NOT NULL DEFAULT 0, + funding_type INTEGER NOT NULL DEFAULT 0, + identity_index INTEGER NOT NULL DEFAULT 0, + proof_data BLOB, + PRIMARY KEY (tx_id, output_index), FOREIGN KEY (identity_id) REFERENCES identity(id) ON DELETE SET NULL, FOREIGN KEY (identity_id_potentially_in_creation) @@ -377,10 +518,10 @@ impl Database { conn.execute( "INSERT INTO asset_lock_transaction - (tx_id, transaction_data, amount, instant_lock_data, + (tx_id, output_index, transaction_data, amount, instant_lock_data, chain_locked_height, identity_id, identity_id_potentially_in_creation, wallet, network) - SELECT tx_id, transaction_data, amount, instant_lock_data, + SELECT tx_id, 0, transaction_data, amount, instant_lock_data, chain_locked_height, identity_id, identity_id_potentially_in_creation, wallet, network FROM asset_lock_transaction_old", diff --git a/src/database/identities.rs b/src/database/identities.rs index 6cb2a25fe..4cda1b680 100644 --- a/src/database/identities.rs +++ b/src/database/identities.rs @@ -6,7 +6,7 @@ use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::platform::Identifier; use rusqlite::{Connection, params}; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; use std::sync::{Arc, RwLock}; impl Database { @@ -299,6 +299,36 @@ impl Database { Ok(identity) } + /// Returns the set of identity wallet indices already used by the given wallet. + /// + /// This queries the `identity` table for all rows belonging to the wallet + /// (identified by seed hash) and returns the `wallet_index` values as a set. + pub fn get_wallet_identity_indices( + &self, + wallet_seed_hash: &WalletSeedHash, + network: Network, + ) -> HashSet { + let network_str = network.to_string(); + let conn = self.conn.lock().unwrap(); + let mut stmt = conn + .prepare( + "SELECT wallet_index FROM identity \ + WHERE wallet = ?1 AND network = ?2 AND wallet_index IS NOT NULL", + ) + .unwrap_or_else(|e| { + tracing::warn!("Failed to prepare wallet identity indices query: {e}"); + // Return an empty set below via early return isn't possible here, + // but the query is simple enough that prepare shouldn't fail. + panic!("Failed to prepare wallet identity indices query: {e}"); + }); + stmt.query_map( + rusqlite::params![wallet_seed_hash.as_slice(), network_str], + |row| row.get::<_, u32>(0), + ) + .map(|rows| rows.filter_map(|r| r.ok()).collect()) + .unwrap_or_default() + } + /// Stops on the first corrupted identity blob and returns an error. /// This is intentional — identities hold private keys and balance data, /// so skipping a corrupted entry could cause loss of funds. diff --git a/src/database/initialization.rs b/src/database/initialization.rs index 42805033d..0394e065a 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"; @@ -51,6 +51,9 @@ impl Database { fn apply_version_changes(&self, version: u16, tx: &Connection) -> rusqlite::Result<()> { match version { + 34 => { + self.add_asset_lock_tracking_columns(tx)?; + } // 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. @@ -410,7 +413,8 @@ impl Database { // Create asset lock transaction table conn.execute( "CREATE TABLE IF NOT EXISTS asset_lock_transaction ( - tx_id BLOB PRIMARY KEY, + tx_id BLOB NOT NULL, + output_index INTEGER NOT NULL DEFAULT 0, transaction_data BLOB NOT NULL, amount INTEGER, instant_lock_data BLOB, @@ -419,6 +423,11 @@ impl Database { identity_id_potentially_in_creation BLOB, wallet BLOB NOT NULL, network TEXT NOT NULL, + account_index INTEGER NOT NULL DEFAULT 0, + funding_type INTEGER NOT NULL DEFAULT 0, + identity_index INTEGER NOT NULL DEFAULT 0, + proof_data BLOB, + PRIMARY KEY (tx_id, output_index), FOREIGN KEY (identity_id) REFERENCES identity(id) ON DELETE SET NULL, FOREIGN KEY (identity_id_potentially_in_creation) REFERENCES identity(id) ON DELETE SET NULL, FOREIGN KEY (wallet) REFERENCES wallet(seed_hash) ON DELETE CASCADE @@ -974,6 +983,81 @@ impl Database { } Ok(()) } + + /// Migration 34: Recreate `asset_lock_transaction` with composite primary key + /// `(tx_id, output_index)` and add tracking columns for full `TrackedAssetLock` + /// round-trip persistence. + fn add_asset_lock_tracking_columns(&self, conn: &Connection) -> rusqlite::Result<()> { + // Check if already migrated (has the output_index column). + let has_output_index: bool = conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info('asset_lock_transaction') WHERE name='output_index'", + [], + |row| row.get::<_, i32>(0).map(|c| c > 0), + )?; + if has_output_index { + return Ok(()); + } + + // Recreate the table with composite PK and new columns. + conn.execute("PRAGMA foreign_keys = OFF", [])?; + + conn.execute( + "ALTER TABLE asset_lock_transaction RENAME TO asset_lock_transaction_old", + [], + )?; + + conn.execute( + "CREATE TABLE asset_lock_transaction ( + tx_id BLOB NOT NULL, + output_index INTEGER NOT NULL DEFAULT 0, + transaction_data BLOB NOT NULL, + amount INTEGER, + instant_lock_data BLOB, + chain_locked_height INTEGER, + identity_id BLOB, + identity_id_potentially_in_creation BLOB, + wallet BLOB NOT NULL, + network TEXT NOT NULL, + account_index INTEGER NOT NULL DEFAULT 0, + funding_type INTEGER NOT NULL DEFAULT 0, + identity_index INTEGER NOT NULL DEFAULT 0, + proof_data BLOB, + PRIMARY KEY (tx_id, output_index), + FOREIGN KEY (identity_id) + REFERENCES identity(id) ON DELETE SET NULL, + FOREIGN KEY (identity_id_potentially_in_creation) + REFERENCES identity(id) ON DELETE SET NULL, + FOREIGN KEY (wallet) + REFERENCES wallet(seed_hash) ON DELETE CASCADE + )", + [], + )?; + + // Copy existing rows — new columns get defaults (0 / NULL). + conn.execute( + "INSERT INTO asset_lock_transaction + (tx_id, output_index, transaction_data, amount, instant_lock_data, + chain_locked_height, identity_id, identity_id_potentially_in_creation, + wallet, network) + SELECT tx_id, 0, transaction_data, amount, instant_lock_data, + chain_locked_height, identity_id, + identity_id_potentially_in_creation, wallet, network + FROM asset_lock_transaction_old", + [], + )?; + + conn.execute("DROP TABLE asset_lock_transaction_old", [])?; + + // Recreate index that existed before. + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_asset_lock_transaction_network ON asset_lock_transaction (network)", + [], + )?; + + conn.execute("PRAGMA foreign_keys = ON", [])?; + + Ok(()) + } } #[cfg(test)] @@ -1144,7 +1228,7 @@ mod test { ) .unwrap(); assert_eq!(version, DEFAULT_DB_VERSION); - assert_eq!(version, 33); + assert_eq!(version, 34); assert_v33_schema(&conn); } @@ -1261,7 +1345,7 @@ mod test { ); // Verify final version - assert_eq!(db.db_schema_version().unwrap(), 33); + assert_eq!(db.db_schema_version().unwrap(), 34); // Verify full v33 schema let conn = db.conn.lock().unwrap(); diff --git a/src/database/single_key_wallet.rs b/src/database/single_key_wallet.rs index c12ad4318..4e43b7608 100644 --- a/src/database/single_key_wallet.rs +++ b/src/database/single_key_wallet.rs @@ -4,7 +4,8 @@ use crate::database::Database; use crate::model::wallet::single_key::{ ClosedSingleKey, SingleKeyData, SingleKeyHash, SingleKeyWallet, }; -use dash_sdk::dpp::dashcore::{Address, Network, PublicKey}; +use dash_sdk::dpp::dashcore::hashes::Hash; +use dash_sdk::dpp::dashcore::{Address, Network, OutPoint, PublicKey, TxOut}; use rusqlite::{Connection, params}; use std::collections::HashMap; @@ -292,4 +293,81 @@ impl Database { )?; Ok(()) } + + /// Atomically persist balance and UTXO changes after spending from a single key wallet. + /// + /// Drops spent UTXOs and updates balances in a single SQLite transaction. + pub fn persist_single_key_wallet_spend( + &self, + key_hash: &SingleKeyHash, + spent_outpoints: &[OutPoint], + balance: u64, + network: &str, + ) -> rusqlite::Result<()> { + let mut conn = self.conn.lock().unwrap(); + let tx = conn.transaction()?; + + for outpoint in spent_outpoints { + let txid_bytes = outpoint.txid.as_byte_array(); + let vout = outpoint.vout as i64; + tx.execute( + "DELETE FROM utxos WHERE txid = ? AND vout = ? AND network = ?", + params![txid_bytes, vout, network], + )?; + } + + tx.execute( + "UPDATE single_key_wallet SET + confirmed_balance = ?1, + unconfirmed_balance = ?2, + total_balance = ?3 + WHERE key_hash = ?4", + params![balance as i64, 0i64, balance as i64, key_hash.as_slice()], + )?; + + tx.commit() + } + + /// Atomically persist balance and UTXOs after refreshing a single key wallet from RPC. + /// + /// Updates balances and inserts all current UTXOs in a single SQLite transaction. + pub fn persist_single_key_wallet_refresh( + &self, + key_hash: &SingleKeyHash, + balance: u64, + utxos: &HashMap, + address: &Address, + network: Network, + ) -> rusqlite::Result<()> { + let mut conn = self.conn.lock().unwrap(); + let tx = conn.transaction()?; + + tx.execute( + "UPDATE single_key_wallet SET + confirmed_balance = ?1, + unconfirmed_balance = ?2, + total_balance = ?3 + WHERE key_hash = ?4", + params![balance as i64, 0i64, balance as i64, key_hash.as_slice()], + )?; + + let address_str = address.to_string(); + let network_str = network.to_string(); + for (outpoint, tx_out) in utxos { + tx.execute( + "INSERT OR IGNORE INTO utxos (txid, vout, address, value, script_pubkey, network) + VALUES (?, ?, ?, ?, ?, ?)", + params![ + outpoint.txid.as_byte_array().as_slice(), + outpoint.vout, + address_str, + tx_out.value, + tx_out.script_pubkey.as_bytes(), + network_str + ], + )?; + } + + tx.commit() + } } diff --git a/src/database/wallet.rs b/src/database/wallet.rs index 841a5221c..99618d253 100644 --- a/src/database/wallet.rs +++ b/src/database/wallet.rs @@ -1,26 +1,19 @@ use crate::database::{CorruptedBlobError, Database}; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::{ - AddressInfo, ClosedKeyItem, DerivationPathReference, DerivationPathType, OpenWalletSeed, - TransactionStatus, Wallet, WalletSeed, WalletTransaction, + ClosedKeyItem, DerivationPathReference, DerivationPathType, OpenWalletSeed, Wallet, WalletSeed, + WalletTransaction, }; use dash_sdk::dashcore_rpc::dashcore::Address; -use dash_sdk::dashcore_rpc::dashcore::transaction::special_transaction::TransactionPayload; -use dash_sdk::dpp::balances::credits::Duffs; use dash_sdk::dpp::dashcore::address::{NetworkChecked, NetworkUnchecked}; -use dash_sdk::dpp::dashcore::consensus::{deserialize, serialize}; +use dash_sdk::dpp::dashcore::consensus::serialize; use dash_sdk::dpp::dashcore::hashes::Hash; -use dash_sdk::dpp::dashcore::{ - self, BlockHash, InstantLock, Network, OutPoint, ScriptBuf, Transaction, TxOut, Txid, -}; +use dash_sdk::dpp::dashcore::{self, Network}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; -use dash_sdk::dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; -use dash_sdk::dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; use dash_sdk::dpp::key_wallet::bip32::{DerivationPath, ExtendedPubKey}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; -use dash_sdk::dpp::prelude::{AssetLockProof, CoreBlockHeight}; use rusqlite::{Connection, params}; -use std::collections::{BTreeMap, HashMap}; +use std::collections::BTreeMap; use std::str::FromStr; impl Database { @@ -65,9 +58,9 @@ impl Database { wallet.uses_password, wallet.password_hint().clone(), network_str, - wallet.confirmed_balance as i64, - wallet.unconfirmed_balance as i64, - wallet.total_balance as i64, + wallet.confirmed_balance_duffs() as i64, + wallet.unconfirmed_balance_duffs() as i64, + wallet.total_balance_duffs() as i64, wallet.core_wallet_name.as_deref(), ], )?; @@ -502,9 +495,8 @@ impl Database { let is_main: bool = row.get(6)?; let uses_password: bool = row.get(7)?; let password_hint: Option = row.get(8)?; - let confirmed_balance: i64 = row.get::<_, Option>(9)?.unwrap_or(0); - let unconfirmed_balance: i64 = row.get::<_, Option>(10)?.unwrap_or(0); - let total_balance: i64 = row.get::<_, Option>(11)?.unwrap_or(0); + // Balances at indices 9-11 are persisted but no longer loaded into + // the Wallet struct — they are served by PlatformWallet's WalletBalance. let core_wallet_name: Option = row.get(12)?; // Reconstruct the extended public keys @@ -564,24 +556,12 @@ impl Database { wallets_map.insert( seed_hash_array, Wallet { + platform_wallet: None, wallet_seed, uses_password, master_bip44_ecdsa_extended_public_key: master_ecdsa_extended_public_key, - address_balances: BTreeMap::new(), - address_total_received: BTreeMap::new(), - known_addresses: BTreeMap::new(), - watched_addresses: BTreeMap::new(), - unused_asset_locks: vec![], alias, - identities: HashMap::new(), - utxos: HashMap::new(), - transactions: Vec::new(), is_main, - 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, }, ); @@ -594,347 +574,20 @@ impl Database { wallet?; } - tracing::trace!( - "step 2: retrieve all addresses, balances, and derivation paths associated with the wallets" - ); - let mut address_stmt = conn.prepare( - "SELECT seed_hash, address, derivation_path, balance, path_reference, path_type, total_received FROM wallet_addresses WHERE seed_hash IN (SELECT seed_hash FROM wallet WHERE network = ?)", - )?; - - let address_rows = address_stmt.query_map([network_str.clone()], |row| { - let seed_hash: Vec = row.get(0)?; - let address_str: String = row.get(1)?; - let derivation_path: String = row.get(2)?; - let balance: Option = row.get(3)?; - let path_reference: u32 = row.get(4)?; - let path_type: u32 = row.get(5)?; - let total_received: Option = row.get(6)?; - - let seed_hash_array: [u8; 32] = seed_hash.try_into().map_err(|_| { - rusqlite::Error::InvalidParameterName( - "Seed hash should be 32 bytes".to_string(), - ) - })?; - - // Convert u32 to DerivationPathReference safely - let path_reference = - DerivationPathReference::try_from(path_reference).map_err(|_| { - rusqlite::Error::FromSqlConversionFailure( - 0, - rusqlite::types::Type::Integer, - Box::new(std::fmt::Error), - ) - })?; - - // Parse address - Platform addresses (DIP-17/18) use Bech32m encoding with dash/tdash HRP per DIP-18 - // and need special handling when stored (we store as Core address format internally) - let address = if path_reference == DerivationPathReference::PlatformPayment { - // Platform addresses are stored as Core P2PKH format for efficient internal lookup. - // We use assume_checked() here because: - // 1. Network validation was already performed at insertion time - // 2. Platform addresses (bech32m) map to Core P2PKH addresses internally - // 3. The stored address format doesn't have the same network version byte rules - Address::from_str(&address_str) - .map(|a| a.assume_checked()) - .map_err(|e| { - tracing::error!(address = %address_str, error = ?e, "Failed to parse Platform address"); - rusqlite::Error::FromSqlConversionFailure( - 1, - rusqlite::types::Type::Text, - Box::new(std::fmt::Error), - ) - })? - } else { - // Standard Core addresses - validate network - let address_unchecked = - Address::from_str(&address_str).map_err(|e| { - rusqlite::Error::InvalidParameterName(format!( - "Invalid address format '{}': {}", - address_str, e - )) - })?; - check_address_for_network(address_unchecked, network)? - }; - - let derivation_path = DerivationPath::from_str(&derivation_path).map_err(|e| { - rusqlite::Error::InvalidParameterName(format!( - "Invalid derivation path '{}': {}", - derivation_path, e - )) - })?; - - let path_type = DerivationPathType::from_bits_truncate(path_type); - - Ok(( - seed_hash_array, - address, - derivation_path, - balance, - path_reference, - path_type, - total_received, - )) - })?; - - tracing::trace!("step 3: add addresses, balances, and known addresses to wallets"); - for row in address_rows { - if row.is_err() { - continue; - } - let ( - seed_array, - address, - derivation_path, - balance, - path_reference, - path_type, - total_received, - ) = row?; - if let Some(wallet) = wallets_map.get_mut(&seed_array) { - // Canonicalize Platform addresses to avoid duplicate representations - let canonical_address = Wallet::canonical_address(&address, *network); - - // Update the address balance if available. - if let Some(balance) = balance { - wallet - .address_balances - .insert(canonical_address.clone(), balance); - } - // Update total received if available. - if let Some(total_received) = total_received { - wallet - .address_total_received - .insert(canonical_address.clone(), total_received); - } - - // Add the address to the `known_addresses` map. - wallet - .known_addresses - .insert(canonical_address.clone(), derivation_path.clone()); - tracing::trace!( - address = ?canonical_address, - network = address.network().to_string(), - expected_network = network.to_string(), - "loaded address from database"); - - // Add the address to the `watched_addresses` map with AddressInfo. - let address_info = AddressInfo { - address: canonical_address.clone(), - path_reference, - path_type, - }; - wallet - .watched_addresses - .insert(derivation_path, address_info); - } - } - - tracing::trace!("step 4: retrieve UTXOs for each wallet and add them to the wallets"); - let mut utxo_stmt = conn.prepare( - "SELECT txid, vout, address, value, script_pubkey FROM utxos WHERE network = ?", - )?; - - let utxo_rows = utxo_stmt.query_map([network_str.clone()], |row| { - let txid: Vec = row.get(0)?; - let vout: i64 = row.get(1)?; - let address: String = row.get(2)?; - let value: i64 = row.get(3)?; - let script_pubkey: Vec = row.get(4)?; - - let address = Address::from_str(&address) - .map_err(|e| { - rusqlite::Error::InvalidParameterName(format!( - "Invalid UTXO address format '{}': {}", - address, e - )) - })? - .assume_checked(); - - let outpoint = OutPoint { - txid: Txid::from_slice(&txid).map_err(|e| { - rusqlite::Error::InvalidParameterName(format!("Invalid UTXO txid: {}", e)) - })?, - vout: vout as u32, - }; - let tx_out = TxOut { - value: value as u64, - script_pubkey: ScriptBuf::from_bytes(script_pubkey), - }; - Ok((address, outpoint, tx_out)) - })?; - - tracing::trace!("step 5: add the UTXOs to the corresponding wallets."); - for row in utxo_rows { - let (address, outpoint, tx_out) = row?; - - for wallet in wallets_map.values_mut() { - if wallet.known_addresses.contains_key(&address) { - wallet - .utxos - .entry(address.clone()) - .or_insert_with(HashMap::new) - .insert(outpoint, tx_out.clone()); - } - } - } - tracing::trace!("step 6: load asset lock transactions for each wallet"); - let mut asset_lock_stmt = conn.prepare( - "SELECT wallet, amount, transaction_data, instant_lock_data, chain_locked_height FROM asset_lock_transaction where identity_id IS NULL AND network = ?", - )?; - - let asset_lock_rows = asset_lock_stmt.query_map([network.to_string()], |row| { - let wallet_seed: Vec = row.get(0)?; - let amount: Duffs = row.get(1)?; - let tx_data: Vec = row.get(2)?; - let islock_data: Option> = row.get(3)?; - let chain_locked_height: Option = row.get(4)?; - - let wallet_seed_hash_array: [u8; 32] = wallet_seed.try_into().map_err(|_| { - rusqlite::Error::InvalidParameterName("Wallet seed should be 32 bytes".to_string()) - })?; - let tx: Transaction = deserialize(&tx_data).map_err(|e| { - rusqlite::Error::InvalidParameterName(format!( - "Failed to deserialize asset lock transaction: {}", - e - )) - })?; - - // Ensure the transaction payload is AssetLockPayloadType - let Some(TransactionPayload::AssetLockPayloadType(payload)) = - &tx.special_transaction_payload - else { - return Err(rusqlite::Error::InvalidParameterName( - "Expected AssetLockPayloadType in special_transaction_payload".to_string(), - )); - }; - - // Get the first credit output - let first = - payload - .credit_outputs - .first() - .ok_or(rusqlite::Error::InvalidParameterName( - "Expected at least one credit output in asset lock".to_string(), - ))?; - - let address = Address::from_script(&first.script_pubkey, *network).map_err(|e| { - rusqlite::Error::InvalidParameterName(format!( - "Failed to derive address from credit output: {}", - e - )) - })?; - - let (islock, proof) = if let Some(islock_bytes) = islock_data { - // Deserialize the InstantLock - let is_lock: InstantLock = deserialize(&islock_bytes).map_err(|e| { - rusqlite::Error::InvalidParameterName(format!( - "Failed to deserialize InstantLock: {}", - e - )) - })?; - ( - Some(is_lock.clone()), - Some(AssetLockProof::Instant(InstantAssetLockProof::new( - is_lock, - tx.clone(), - 0, - ))), - ) - } else if let Some(chain_locked_height) = chain_locked_height { - ( - None, - Some(AssetLockProof::Chain(ChainAssetLockProof { - core_chain_locked_height: chain_locked_height, - out_point: OutPoint::new(tx.txid(), 0), - })), - ) - } else { - (None, None) - }; - - Ok((wallet_seed_hash_array, tx, address, amount, islock, proof)) - })?; - - tracing::trace!("step 7: add the asset lock transactions to the wallet"); - for row in asset_lock_rows { - let (wallet_seed, tx, address, amount, islock, proof) = row?; - - if let Some(wallet) = wallets_map.get_mut(&wallet_seed) { - wallet - .unused_asset_locks - .push((tx, address, amount, islock, proof)); - } - } - - tracing::trace!("step 7: load wallet transactions for each wallet"); - let mut tx_stmt = conn.prepare( - "SELECT seed_hash, txid, timestamp, height, block_hash, net_amount, fee, label, is_ours, raw_transaction, status - FROM wallet_transactions WHERE network = ? ORDER BY timestamp DESC", - )?; + // Address rows from wallet_addresses are no longer loaded into the + // Wallet struct — PlatformWallet's ManagedWalletInfo is the canonical + // source for address metadata and balances. - let tx_rows = tx_stmt.query_map([network_str.clone()], |row| { - let seed_hash: Vec = row.get(0)?; - let txid_bytes: Vec = row.get(1)?; - let timestamp: i64 = row.get(2)?; - let height: Option = row.get(3)?; - let block_hash_bytes: Option> = row.get(4)?; - let net_amount: i64 = row.get(5)?; - let fee: Option = row.get(6)?; - let label: Option = row.get(7)?; - let is_ours: bool = row.get(8)?; - let raw_transaction: Vec = row.get(9)?; - let status_u8: u8 = row.get(10)?; + // UTXOs are tracked by ManagedWalletInfo — no longer loaded into the + // Wallet struct. - let seed_hash_array: [u8; 32] = seed_hash.try_into().map_err(|_| { - rusqlite::Error::InvalidParameterName("Seed hash should be 32 bytes".to_string()) - })?; - let txid = Txid::from_slice(&txid_bytes).map_err(|e| { - rusqlite::Error::InvalidParameterName(format!("Invalid transaction txid: {}", e)) - })?; - let transaction: Transaction = deserialize(&raw_transaction).map_err(|e| { - rusqlite::Error::InvalidParameterName(format!( - "Failed to deserialize transaction: {}", - e - )) - })?; - let block_hash = block_hash_bytes - .as_ref() - .map(|bytes| { - BlockHash::from_slice(bytes).map_err(|e| { - rusqlite::Error::InvalidParameterName(format!("Invalid block hash: {}", e)) - }) - }) - .transpose()?; - let fee = fee.map(|f| f as u64); - let height = height.map(|h| h as u32); - - Ok(( - seed_hash_array, - WalletTransaction { - txid, - transaction, - timestamp: timestamp as u64, - height, - block_hash, - net_amount, - fee, - label, - is_ours, - status: TransactionStatus::from_u8(status_u8), - }, - )) - })?; - - for row in tx_rows { - let (seed_hash, transaction) = row?; - if let Some(wallet) = wallets_map.get_mut(&seed_hash) { - wallet.transactions.push(transaction); - } - } + // Asset locks are loaded via AssetLockManager's changeset persistence + // (restore_from_changeset_blocking in PlatformWallet::apply), not from + // the legacy asset_lock_transaction table into the Wallet struct. tracing::trace!( network = network_str, - "step 8: retrieve identities for wallets" + "step 5: retrieve identities for wallets" ); let mut identity_stmt = conn.prepare( "SELECT data, wallet, wallet_index FROM identity WHERE network = ? AND wallet IS NOT NULL AND wallet_index IS NOT NULL", @@ -985,51 +638,11 @@ impl Database { wallet_index = wallet_index, "adding identity to wallet" ); - // Insert the identity into the wallet's identities HashMap with wallet_index as the key - wallet.identities.insert(wallet_index, identity.identity); } } - tracing::trace!( - network = network_str, - "step 9: retrieve platform address info for wallets" - ); - // 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 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 seed_hash_array: [u8; 32] = seed_hash.try_into().map_err(|_| { - rusqlite::Error::InvalidParameterName("Seed hash should be 32 bytes".to_string()) - })?; - 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)) = row - && let Some(wallet) = wallets_map.get_mut(&seed_hash) - && let Ok(address) = Address::::from_str(&address_str) - { - let address_checked = address.require_network(*network).map_err(|e| { - tracing::error!(address = %address_str, error = ?e, "Failed to validate Platform address for network"); - rusqlite::Error::FromSqlConversionFailure( - 1, - rusqlite::types::Type::Text, - Box::new(std::fmt::Error), - ) - })?; - let canonical_address = Wallet::canonical_address(&address_checked, *network); - - wallet.platform_address_info.insert( - canonical_address, - crate::model::wallet::PlatformAddressInfo { balance, nonce }, - ); - } - } + // Platform address info is stored in the platform_address_balances DB table + // and read on demand — no longer cached in the Wallet struct. // Convert the BTreeMap into a Vec of Wallets. Ok(wallets_map.into_values().collect()) diff --git a/src/lib.rs b/src/lib.rs index 9ba4b4ec2..ddf9093e4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ pub mod app; pub mod app_dir; pub mod backend_task; pub mod bundled; +pub mod changeset; pub mod components; pub mod config; pub mod context; @@ -14,6 +15,7 @@ pub mod logging; pub mod mcp; pub mod model; pub mod platform; +pub mod platform_wallet_bridge; pub mod sdk_wrapper; pub mod spv; pub mod ui; diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index 7ec440bcf..7bca15670 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -47,15 +47,12 @@ pub async fn start_http_server( let api_key = ApiKey(Arc::from(config.api_key.as_str())); let ctx = app_context.clone(); + let mut server_config = StreamableHttpServerConfig::default(); + server_config.cancellation_token = cancel.clone(); let mcp_service = StreamableHttpService::new( move || Ok(DashMcpService::new_shared(ctx.clone())), LocalSessionManager::default().into(), - { - StreamableHttpServerConfig { - cancellation_token: cancel.clone(), - ..Default::default() - } - }, + server_config, ); let health = Router::new().route("/health", axum::routing::get(|| async { "OK" })); diff --git a/src/mcp/resolve.rs b/src/mcp/resolve.rs index ef0c42d20..d95a8df9f 100644 --- a/src/mcp/resolve.rs +++ b/src/mcp/resolve.rs @@ -183,6 +183,20 @@ pub(crate) fn qualified_identity( }) } +/// Get the `PlatformWallet` for a given seed hash. +/// +/// Returns `McpToolError::WalletNotFound` if no platform wallet is registered +/// for this seed hash (e.g. wallet not unlocked yet). +pub(crate) fn platform_wallet( + ctx: &AppContext, + seed_hash: WalletSeedHash, +) -> Result, McpToolError> { + ctx.get_platform_wallet(&seed_hash) + .ok_or_else(|| McpToolError::WalletNotFound { + id: hex::encode(seed_hash), + }) +} + /// Validate amount in credits for sending operations. pub(crate) fn validate_credits(amount_credits: u64) -> Result<(), McpToolError> { if amount_credits == 0 { diff --git a/src/mcp/tools/identity.rs b/src/mcp/tools/identity.rs index 2dc21a096..bf602bd1f 100644 --- a/src/mcp/tools/identity.rs +++ b/src/mcp/tools/identity.rs @@ -205,18 +205,21 @@ impl AsyncTool for IdentityCreditsTopupFromPlatform { .id() .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58); - // Read platform addresses from wallet state and allocate inputs (scoped to avoid Send issues) + // Read platform addresses from DB and allocate inputs (scoped to avoid Send issues) let platform_balances = { let wallet_arc = resolve::wallet_arc(&ctx, seed_hash)?; let wallet = wallet_arc.read().unwrap_or_else(|e| e.into_inner()); - let mut balances: Vec<_> = wallet - .platform_address_info - .iter() - .filter_map(|(addr, info)| { - if info.balance > 0 { - dash_sdk::dpp::address_funds::PlatformAddress::try_from(addr.clone()) + let db_info = ctx + .db + .get_all_platform_address_info(&wallet.seed_hash(), &ctx.network) + .unwrap_or_default(); + let mut balances: Vec<_> = db_info + .into_iter() + .filter_map(|(addr, balance, _nonce)| { + if balance > 0 { + dash_sdk::dpp::address_funds::PlatformAddress::try_from(addr) .ok() - .map(|pa| (pa, info.balance)) + .map(|pa| (pa, balance)) } else { None } diff --git a/src/mcp/tools/shielded.rs b/src/mcp/tools/shielded.rs index 6f70a7b47..bb0a023b3 100644 --- a/src/mcp/tools/shielded.rs +++ b/src/mcp/tools/shielded.rs @@ -197,14 +197,17 @@ impl AsyncTool for ShieldedShieldFromPlatform { let from_address = { let wallet_arc = resolve::wallet_arc(&ctx, seed_hash)?; let wallet = wallet_arc.read().unwrap_or_else(|e| e.into_inner()); - let best = wallet - .platform_address_info - .iter() - .filter_map(|(addr, info)| { - if info.balance > 0 { - dash_sdk::dpp::address_funds::PlatformAddress::try_from(addr.clone()) + let db_info = ctx + .db + .get_all_platform_address_info(&wallet.seed_hash(), &ctx.network) + .unwrap_or_default(); + let best = db_info + .into_iter() + .filter_map(|(addr, balance, _nonce)| { + if balance > 0 { + dash_sdk::dpp::address_funds::PlatformAddress::try_from(addr) .ok() - .map(|pa| (pa, info.balance)) + .map(|pa| (pa, balance)) } else { None } diff --git a/src/mcp/tools/wallet.rs b/src/mcp/tools/wallet.rs index 8b76506ab..f5393b54e 100644 --- a/src/mcp/tools/wallet.rs +++ b/src/mcp/tools/wallet.rs @@ -70,7 +70,8 @@ impl AsyncTool for GenerateReceiveAddress { resolve::ensure_spv_synced(&ctx).await?; - if ctx.spv_manager.wallet_id_for_seed(seed_hash).is_none() { + // Verify the wallet has a PlatformWallet (required for SPV operations) + if ctx.get_platform_wallet(&seed_hash).is_none() { return Err(McpToolError::Internal( "Wallet is not loaded into SPV. Please retry in a moment.".to_string(), )); @@ -143,14 +144,21 @@ impl AsyncTool for WalletBalancesQuery { resolve::ensure_spv_synced(&ctx).await?; - let wallet_arc = resolve::wallet_arc(&ctx, seed_hash)?; - let wallet = wallet_arc.read().unwrap_or_else(|e| e.into_inner()); + // Read alias from evo-tool Wallet (still the owner of metadata). + let alias = resolve::wallet_arc(&ctx, seed_hash) + .ok() + .and_then(|arc| arc.read().ok().and_then(|w| w.alias.clone())); + + // Read balances from PlatformWallet's lock-free atomics — no RwLock + // needed, instant read. + let pw = resolve::platform_wallet(&ctx, seed_hash)?; + let bal = pw.core().balance(); Ok(WalletBalancesOutput { - alias: wallet.alias.clone(), - total_duffs: wallet.total_balance_duffs(), - confirmed_duffs: wallet.confirmed_balance_duffs(), - unconfirmed_duffs: wallet.unconfirmed_balance_duffs(), + alias, + total_duffs: bal.total(), + confirmed_duffs: bal.spendable(), + unconfirmed_duffs: bal.unconfirmed(), }) } } diff --git a/src/model/qualified_identity/qualified_identity_public_key.rs b/src/model/qualified_identity/qualified_identity_public_key.rs index 5cede03a6..67fd93d7d 100644 --- a/src/model/qualified_identity/qualified_identity_public_key.rs +++ b/src/model/qualified_identity/qualified_identity_public_key.rs @@ -68,26 +68,23 @@ impl QualifiedIdentityPublicKey { // Iterate over each wallet to check for matching derivation paths for locked_wallet in wallets { let wallet = locked_wallet.read().unwrap(); - if let Some(derivation_path) = wallet.known_addresses.get(&address) { + if let Some(derivation_path) = wallet.derivation_path_for_address(&address) + { in_wallet_at_derivation_path = Some(WalletDerivationPath { wallet_seed_hash: wallet.seed_hash(), - derivation_path: derivation_path.clone(), + derivation_path, }); - } - if in_wallet_at_derivation_path.is_some() { break; } if let Some(testnet_address) = testnet_address.as_ref() { if let Some(derivation_path) = - wallet.known_addresses.get(testnet_address) + wallet.derivation_path_for_address(testnet_address) { in_wallet_at_derivation_path = Some(WalletDerivationPath { wallet_seed_hash: wallet.seed_hash(), - derivation_path: derivation_path.clone(), + derivation_path, }); - } - if in_wallet_at_derivation_path.is_some() { break; } } @@ -109,26 +106,23 @@ impl QualifiedIdentityPublicKey { // Iterate over each wallet to check for matching derivation paths for locked_wallet in wallets { let wallet = locked_wallet.read().unwrap(); - if let Some(derivation_path) = wallet.known_addresses.get(&address) { + if let Some(derivation_path) = wallet.derivation_path_for_address(&address) + { in_wallet_at_derivation_path = Some(WalletDerivationPath { wallet_seed_hash: wallet.seed_hash(), - derivation_path: derivation_path.clone(), + derivation_path, }); - } - if in_wallet_at_derivation_path.is_some() { break; } if let Some(testnet_address) = testnet_address.as_ref() { if let Some(derivation_path) = - wallet.known_addresses.get(testnet_address) + wallet.derivation_path_for_address(testnet_address) { in_wallet_at_derivation_path = Some(WalletDerivationPath { wallet_seed_hash: wallet.seed_hash(), - derivation_path: derivation_path.clone(), + derivation_path, }); - } - if in_wallet_at_derivation_path.is_some() { break; } } @@ -159,24 +153,22 @@ impl QualifiedIdentityPublicKey { // Iterate over each wallet to check for matching derivation paths for locked_wallet in wallets { let wallet = locked_wallet.read().unwrap(); - if let Some(derivation_path) = wallet.known_addresses.get(&address) { + if let Some(derivation_path) = wallet.derivation_path_for_address(&address) { in_wallet_at_derivation_path = Some(WalletDerivationPath { wallet_seed_hash: wallet.seed_hash(), - derivation_path: derivation_path.clone(), + derivation_path, }); - } - if in_wallet_at_derivation_path.is_some() { break; } if let Some(testnet_address) = testnet_address.as_ref() { - if let Some(derivation_path) = wallet.known_addresses.get(testnet_address) { + if let Some(derivation_path) = + wallet.derivation_path_for_address(testnet_address) + { in_wallet_at_derivation_path = Some(WalletDerivationPath { wallet_seed_hash: wallet.seed_hash(), - derivation_path: derivation_path.clone(), + derivation_path, }); - } - if in_wallet_at_derivation_path.is_some() { break; } } diff --git a/src/model/wallet/asset_lock_transaction.rs b/src/model/wallet/asset_lock_transaction.rs deleted file mode 100644 index ad9a751ac..000000000 --- a/src/model/wallet/asset_lock_transaction.rs +++ /dev/null @@ -1,757 +0,0 @@ -use crate::context::AppContext; -use crate::model::wallet::Wallet; -use dash_sdk::dashcore_rpc::dashcore::key::Secp256k1; -use dash_sdk::dpp::dashcore::secp256k1::Message; -use dash_sdk::dpp::dashcore::sighash::SighashCache; -use dash_sdk::dpp::dashcore::transaction::special_transaction::TransactionPayload; -use dash_sdk::dpp::dashcore::transaction::special_transaction::asset_lock::AssetLockPayload; -use dash_sdk::dpp::dashcore::{ - Address, Network, OutPoint, PrivateKey, ScriptBuf, Transaction, TxIn, TxOut, -}; -use dash_sdk::dpp::key_wallet::psbt::serialize::Serialize; -use std::collections::BTreeMap; - -/// Result of asset lock fee calculation. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct AssetLockFeeResult { - /// Transaction fee in duffs. - pub fee: u64, - /// Actual amount for the asset lock output (may differ from requested if - /// `allow_take_fee_from_amount` is true). - pub actual_amount: u64, - /// Change to return, or `None` if no change output is needed. - pub change: Option, -} - -/// Minimum fee for an asset lock transaction (duffs). -const MIN_ASSET_LOCK_FEE: u64 = 3_000; - -/// Minimum value for a change output (duffs). Outputs below this -/// threshold are considered dust and will be rejected by the network. -/// For P2PKH outputs the Dash Core dust limit is 546 duffs. -const DUST_THRESHOLD: u64 = 546; - -/// Estimate the transaction size in bytes. -/// -/// Assumes P2PKH inputs (~148 B each), standard outputs (~34 B each), -/// a ~10 B header, and a ~60 B asset-lock payload. -fn estimate_tx_size(num_inputs: usize, num_outputs: usize) -> usize { - 10 + (num_inputs * 148) + (num_outputs * 34) + 60 -} - -/// Calculate fee, actual amount, and change for an asset lock transaction. -/// -/// Uses an iterative approach: starts assuming a change output exists, -/// then recomputes if the change disappears under the real fee. -/// -/// # Errors -/// -/// Returns an error string if there are insufficient funds. -pub(crate) fn calculate_asset_lock_fee( - total_input_value: u64, - requested_amount: u64, - num_inputs: usize, - allow_take_fee_from_amount: bool, -) -> Result { - // First pass: assume 2 outputs (1 burn + 1 change). - let fee_with_change = std::cmp::max(MIN_ASSET_LOCK_FEE, estimate_tx_size(num_inputs, 2) as u64); - - let required_with_change = requested_amount - .checked_add(fee_with_change) - .ok_or("Overflow computing required amount + fee")?; - let tentative_change = total_input_value.checked_sub(required_with_change); - - // If change exceeds dust threshold, include it as an output. - if let Some(change) = tentative_change - && change >= DUST_THRESHOLD - { - return Ok(AssetLockFeeResult { - fee: fee_with_change, - actual_amount: requested_amount, - change: Some(change), - }); - } - - // Change is zero or negative under the 2-output fee. - // Recompute with 1 output (no change). - let fee_no_change = std::cmp::max(MIN_ASSET_LOCK_FEE, estimate_tx_size(num_inputs, 1) as u64); - - let required_no_change = requested_amount - .checked_add(fee_no_change) - .ok_or("Overflow computing required amount + fee")?; - - if total_input_value >= required_no_change { - // Enough funds without a change output. Any leftover becomes - // additional fee (it is too small for a viable change output). - return Ok(AssetLockFeeResult { - fee: total_input_value - requested_amount, - actual_amount: requested_amount, - change: None, - }); - } - - // Insufficient for the requested amount at the 1-output fee rate. - if allow_take_fee_from_amount { - let adjusted = total_input_value.saturating_sub(fee_no_change); - if adjusted == 0 { - return Err("Insufficient funds for transaction fee".to_string()); - } - Ok(AssetLockFeeResult { - fee: fee_no_change, - actual_amount: adjusted, - change: None, - }) - } else { - Err(format!( - "Insufficient funds: need {} + {} fee, have {}", - requested_amount, fee_no_change, total_input_value - )) - } -} - -impl Wallet { - /// Select UTXOs and compute fee, retrying with the real fee if the initial - /// estimate was too low and additional UTXOs are available. - #[allow(clippy::type_complexity)] - fn select_utxos_with_fee_retry( - &self, - amount: u64, - allow_take_fee_from_amount: bool, - source_address: Option<&Address>, - ) -> Result<(BTreeMap, AssetLockFeeResult), String> { - let mut fee_estimate = MIN_ASSET_LOCK_FEE; - - for _ in 0..2 { - let (utxos, _) = self - .select_unspent_utxos_for( - amount, - fee_estimate, - allow_take_fee_from_amount, - source_address, - ) - .ok_or_else(|| { - format!( - "Not enough spendable funds to create asset lock transaction: \ - requested amount {} plus estimated fee {}", - amount, fee_estimate - ) - })?; - - let total_input_value: u64 = utxos.iter().map(|(_, (tx_out, _))| tx_out.value).sum(); - let num_inputs = utxos.len(); - - match calculate_asset_lock_fee( - total_input_value, - amount, - num_inputs, - allow_take_fee_from_amount, - ) { - Ok(fee_result) => return Ok((utxos, fee_result)), - Err(_) if fee_estimate == MIN_ASSET_LOCK_FEE => { - // The real fee may exceed our initial estimate. Recompute - // with a 2-output size estimate and retry UTXO selection so - // we can pick up any additional marginal UTXOs. - fee_estimate = - std::cmp::max(MIN_ASSET_LOCK_FEE, estimate_tx_size(num_inputs, 2) as u64); - continue; - } - Err(e) => return Err(e), - } - } - - Err(format!( - "Not enough spendable funds to create asset lock transaction: \ - requested amount {} plus fee {}", - amount, fee_estimate - )) - } - - #[allow(clippy::type_complexity)] - pub fn registration_asset_lock_transaction( - &mut self, - app_context: &AppContext, - network: Network, - amount: u64, - allow_take_fee_from_amount: bool, - identity_index: u32, - source_address: Option<&Address>, - ) -> Result< - ( - Transaction, - PrivateKey, - Option
, - BTreeMap, - ), - String, - > { - let private_key = - self.identity_registration_ecdsa_private_key(app_context, network, identity_index)?; - self.asset_lock_transaction_from_private_key( - app_context, - network, - amount, - allow_take_fee_from_amount, - private_key, - source_address, - ) - } - - #[allow(clippy::type_complexity, clippy::too_many_arguments)] - pub fn top_up_asset_lock_transaction( - &mut self, - app_context: &AppContext, - network: Network, - amount: u64, - allow_take_fee_from_amount: bool, - identity_index: u32, - top_up_index: u32, - source_address: Option<&Address>, - ) -> Result< - ( - Transaction, - PrivateKey, - Option
, - BTreeMap, - ), - String, - > { - let private_key = self.identity_top_up_ecdsa_private_key( - app_context, - network, - identity_index, - top_up_index, - )?; - self.asset_lock_transaction_from_private_key( - app_context, - network, - amount, - allow_take_fee_from_amount, - private_key, - source_address, - ) - } - - /// Create an asset lock transaction with a randomly generated one-time key. - /// This is used for generic platform address funding (not identity-specific). - #[allow(clippy::type_complexity)] - pub fn generic_asset_lock_transaction( - &mut self, - app_context: &AppContext, - network: Network, - amount: u64, - allow_take_fee_from_amount: bool, - source_address: Option<&Address>, - ) -> Result< - ( - Transaction, - PrivateKey, - Address, - Option
, - BTreeMap, - ), - String, - > { - use bip39::rand::rngs::OsRng; - - // Generate a random private key for the asset lock - let secp = Secp256k1::new(); - let (secret_key, _) = secp.generate_keypair(&mut OsRng); - let private_key = PrivateKey::new(secret_key, network); - let public_key = private_key.public_key(&secp); - - // The asset lock address is where the proof will be tied to - let asset_lock_address = Address::p2pkh(&public_key, network); - - let (tx, returned_private_key, change_address, used_utxos) = self - .asset_lock_transaction_from_private_key( - app_context, - network, - amount, - allow_take_fee_from_amount, - private_key, - source_address, - )?; - - Ok(( - tx, - returned_private_key, - asset_lock_address, - change_address, - used_utxos, - )) - } - - #[allow(clippy::type_complexity)] - fn asset_lock_transaction_from_private_key( - &mut self, - app_context: &AppContext, - network: Network, - amount: u64, - allow_take_fee_from_amount: bool, - private_key: PrivateKey, - source_address: Option<&Address>, - ) -> Result< - ( - Transaction, - PrivateKey, - Option
, - BTreeMap, - ), - String, - > { - let secp = Secp256k1::new(); - let asset_lock_public_key = private_key.public_key(&secp); - - let one_time_key_hash = asset_lock_public_key.pubkey_hash(); - - // Select UTXOs without committing the removal yet. UTXOs are only removed - // from the wallet after the transaction is fully built and signed, so that a - // failure at any later step (fee shortfall, missing private key, …) cannot - // permanently drop UTXOs from the wallet — especially important in SPV mode - // where there is no Core RPC reload fallback. - // - // Note: `&mut self` ensures exclusive access during the entire select → build - // → sign → remove sequence, preventing concurrent UTXO double-selection. - // - // We use an initial fee estimate for UTXO selection, then recalculate the - // real fee based on input count. If the real fee exceeds the estimate and - // the selected UTXOs are insufficient, we retry once with the computed fee - // so that marginal UTXOs are not missed. - let (utxos, fee_result) = - self.select_utxos_with_fee_retry(amount, allow_take_fee_from_amount, source_address)?; - - let actual_amount = fee_result.actual_amount; - let change_option = fee_result.change; - - let payload_output = TxOut { - value: actual_amount, - script_pubkey: ScriptBuf::new_p2pkh(&one_time_key_hash), - }; - let burn_output = TxOut { - value: actual_amount, - script_pubkey: ScriptBuf::new_op_return(&[]), - }; - - let (change_output, change_address) = if let Some(change) = change_option { - let change_address = self.change_address(network, Some(app_context))?; - ( - Some(TxOut { - value: change, - script_pubkey: change_address.script_pubkey(), - }), - Some(change_address), - ) - } else { - (None, None) - }; - - let payload = AssetLockPayload { - version: 1, - credit_outputs: vec![payload_output], - }; - - // Collect inputs from UTXOs - let inputs = utxos - .keys() - .map(|utxo| TxIn { - previous_output: *utxo, - ..Default::default() - }) - .collect(); - - let mut tx = Transaction { - version: 3, - lock_time: 0, - input: inputs, - output: { - let mut outputs = vec![burn_output]; - if let Some(change_output) = change_output { - outputs.push(change_output); - } - outputs - }, - special_transaction_payload: Some(TransactionPayload::AssetLockPayloadType(payload)), - }; - - let sighash_u32 = 1u32; - - let cache = SighashCache::new(&tx); - - // Next, collect the sighashes for each input since that's what we need from the - // cache - let sighashes: Result, String> = tx - .input - .iter() - .enumerate() - .map(|(i, input)| { - let script_pubkey = utxos - .get(&input.previous_output) - .ok_or_else(|| { - format!( - "UTXO not found in selected set for input {}", - input.previous_output - ) - })? - .0 - .script_pubkey - .clone(); - cache - .legacy_signature_hash(i, &script_pubkey, sighash_u32) - .map_err(|e| format!("Failed to compute sighash for input {}: {}", i, e)) - }) - .collect(); - let sighashes = sighashes?; - - // Now we can drop the cache to end the immutable borrow - #[allow(clippy::drop_non_drop)] - drop(cache); - - let mut check_utxos = utxos.clone(); - - tx.input - .iter_mut() - .zip(sighashes.into_iter()) - .try_for_each(|(input, sighash)| { - // You need to provide the actual script_pubkey of the UTXO being spent - let (_, input_address) = - check_utxos.remove(&input.previous_output).ok_or_else(|| { - format!( - "UTXO not found in selected set for input {}", - input.previous_output - ) - })?; - let message = Message::from_digest(sighash.into()); - - let private_key = self - .private_key_for_address(&input_address, network)? - .ok_or(format!( - "Expected address {} to be in wallet", - input_address - ))?; - - // Sign the message with the private key - let sig = secp.sign_ecdsa(&message, &private_key.inner); - - // Serialize the DER-encoded signature and append the sighash type - let mut serialized_sig = sig.serialize_der().to_vec(); - - let mut sig_script = vec![serialized_sig.len() as u8 + 1]; - - sig_script.append(&mut serialized_sig); - - sig_script.push(1); - - let mut serialized_pub_key = private_key.public_key(&secp).serialize(); - - sig_script.push(serialized_pub_key.len() as u8); - sig_script.append(&mut serialized_pub_key); - // Create script_sig - input.script_sig = ScriptBuf::from_bytes(sig_script); - Ok::<(), String>(()) - })?; - - // Transaction is fully built and signed. UTXOs are returned to the caller - // so they can be removed explicitly after successful broadcast. This avoids - // permanently losing UTXO tracking if broadcast fails. - Ok((tx, private_key, change_address, utxos)) - } - - pub fn registration_asset_lock_transaction_for_utxo( - &mut self, - app_context: &AppContext, - network: Network, - utxo: OutPoint, - previous_tx_output: TxOut, - input_address: Address, - identity_index: u32, - ) -> Result<(Transaction, PrivateKey), String> { - let private_key = - self.identity_registration_ecdsa_private_key(app_context, network, identity_index)?; - self.asset_lock_transaction_for_utxo_from_private_key( - network, - utxo, - previous_tx_output, - input_address, - private_key, - ) - } - - #[allow(clippy::too_many_arguments)] - pub fn top_up_asset_lock_transaction_for_utxo( - &mut self, - app_context: &AppContext, - network: Network, - utxo: OutPoint, - previous_tx_output: TxOut, - input_address: Address, - identity_index: u32, - top_up_index: u32, - ) -> Result<(Transaction, PrivateKey), String> { - let private_key = self.identity_top_up_ecdsa_private_key( - app_context, - network, - identity_index, - top_up_index, - )?; - self.asset_lock_transaction_for_utxo_from_private_key( - network, - utxo, - previous_tx_output, - input_address, - private_key, - ) - } - - pub fn asset_lock_transaction_for_utxo_from_private_key( - &mut self, - network: Network, - utxo: OutPoint, - previous_tx_output: TxOut, - input_address: Address, - private_key: PrivateKey, - ) -> Result<(Transaction, PrivateKey), String> { - let secp = Secp256k1::new(); - let asset_lock_public_key = private_key.public_key(&secp); - - let one_time_key_hash = asset_lock_public_key.pubkey_hash(); - - // Single-UTXO path: use calculate_asset_lock_fee for consistency with - // the multi-input path, ensuring dust check and overflow safety. - let fee_result = calculate_asset_lock_fee( - previous_tx_output.value, - previous_tx_output.value.saturating_sub(MIN_ASSET_LOCK_FEE), - 1, // single input - true, // take fee from amount since the UTXO *is* the total - )?; - let output_amount = fee_result.actual_amount; - - let payload_output = TxOut { - value: output_amount, - script_pubkey: ScriptBuf::new_p2pkh(&one_time_key_hash), - }; - let burn_output = TxOut { - value: output_amount, - script_pubkey: ScriptBuf::new_op_return(&[]), - }; - let payload = AssetLockPayload { - version: 1, - credit_outputs: vec![payload_output], - }; - - // we need to get all inputs from utxos to add them to the transaction - - let mut tx_in = TxIn::default(); - #[allow(clippy::field_reassign_with_default)] - { - tx_in.previous_output = utxo; - } - - let sighash_u32 = 1u32; - - let mut tx: Transaction = Transaction { - version: 3, - lock_time: 0, - input: vec![tx_in], - output: vec![burn_output], - special_transaction_payload: Some(TransactionPayload::AssetLockPayloadType(payload)), - }; - - let cache = SighashCache::new(&tx); - - // Next, collect the sighashes for each input since that's what we need from the - // cache - let sighashes: Result, String> = tx - .input - .iter() - .enumerate() - .map(|(i, _)| { - cache - .legacy_signature_hash(i, &previous_tx_output.script_pubkey, sighash_u32) - .map_err(|e| format!("Failed to compute sighash for input {}: {}", i, e)) - }) - .collect(); - let sighashes = sighashes?; - - // Now we can drop the cache to end the immutable borrow - #[allow(clippy::drop_non_drop)] - drop(cache); - - tx.input - .iter_mut() - .zip(sighashes.into_iter()) - .try_for_each(|(input, sighash)| { - // You need to provide the actual script_pubkey of the UTXO being spent - let message = Message::from_digest(sighash.into()); - - let private_key = self - .private_key_for_address(&input_address, network)? - .ok_or(format!( - "Expected address {} to be in wallet for input", - input_address - ))?; - - // Sign the message with the private key - let sig = secp.sign_ecdsa(&message, &private_key.inner); - - // Serialize the DER-encoded signature and append the sighash type - let mut serialized_sig = sig.serialize_der().to_vec(); - - let mut sig_script = vec![serialized_sig.len() as u8 + 1]; - - sig_script.append(&mut serialized_sig); - - sig_script.push(1); - - let mut serialized_pub_key = private_key.public_key(&secp).serialize(); - - sig_script.push(serialized_pub_key.len() as u8); - sig_script.append(&mut serialized_pub_key); - // Create script_sig - input.script_sig = ScriptBuf::from_bytes(sig_script); - Ok::<(), String>(()) - })?; - - Ok((tx, private_key)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn fee_single_input_minimum() { - // 1 input, amount=10000, total=50000. - // With 2 outputs: size = 10 + 148 + 2*34 + 60 = 286 -> fee = max(3000, 286) = 3000. - // Change = 50000 - 10000 - 3000 = 37000. - let result = calculate_asset_lock_fee(50_000, 10_000, 1, false).expect("should succeed"); - assert_eq!(result.fee, 3_000); - assert_eq!(result.actual_amount, 10_000); - assert_eq!(result.change, Some(37_000)); - } - - #[test] - fn fee_scales_with_inputs() { - // 21 inputs, amount=50000, total=200000. - // With 2 outputs: size = 10 + 21*148 + 2*34 + 60 = 3246, fee = max(3000, 3246) = 3246. - // Change = 200000 - 50000 - 3246 = 146754. - let result = calculate_asset_lock_fee(200_000, 50_000, 21, false).expect("should succeed"); - assert!( - result.fee > MIN_ASSET_LOCK_FEE, - "fee should exceed minimum for many inputs" - ); - assert_eq!(result.fee, 3_246); - assert_eq!(result.actual_amount, 50_000); - assert_eq!(result.change, Some(146_754)); - } - - #[test] - fn fee_exact_no_change() { - // 1 input, amount=47000, total=50000. - // With 2 outputs: size = 286, fee = 3000. change = 50000 - 47000 - 3000 = 0 -> None. - // Re-check with 1 output: size = 10 + 148 + 34 + 60 = 252, fee = 3000. - // 50000 >= 47000 + 3000 -> actual fee = 50000 - 47000 = 3000. - let result = calculate_asset_lock_fee(50_000, 47_000, 1, false).expect("should succeed"); - assert_eq!(result.actual_amount, 47_000); - assert_eq!(result.change, None); - } - - #[test] - fn fee_take_from_amount() { - // 1 input, amount=50000, total=50000, allow_take_fee=true. - // With 2 outputs: fee = 3000. change = 50000 - 50000 - 3000 < 0. - // With 1 output: fee = 3000. 50000 < 50000 + 3000. - // Take from amount: actual = 50000 - 3000 = 47000. - let result = calculate_asset_lock_fee(50_000, 50_000, 1, true).expect("should succeed"); - assert_eq!(result.actual_amount, 47_000); - assert_eq!(result.change, None); - } - - #[test] - fn fee_insufficient_funds() { - // 1 input, amount=50000, total=50000, allow_take_fee=false. - let result = calculate_asset_lock_fee(50_000, 50_000, 1, false); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("Insufficient funds")); - } - - #[test] - fn fee_insufficient_for_fee_alone() { - // 1 input, amount=2000, total=2000, allow_take_fee=true. - // Fee = 3000 > total -> adjusted = 2000 - 3000 = 0 (saturating) -> error. - let result = calculate_asset_lock_fee(2_000, 2_000, 1, true); - assert!(result.is_err()); - assert!( - result.unwrap_err().contains("Insufficient funds"), - "should error when total cannot even cover the fee" - ); - } - - #[test] - fn fee_change_eliminated_by_real_fee_should_succeed() { - // BUG TEST: 21 inputs, amount=50000, total=53220. - // - // Initial UTXO selection uses fee estimate of 3000: - // change = 53220 - 50000 - 3000 = 220 -> has_initial_change = true - // - // Buggy code uses 2 outputs: fee = 10 + 21*148 + 2*34 + 60 = 3246 - // 53220 < 50000 + 3246 = 53246 -> ERROR (false insufficient funds) - // - // Correct code recalculates with 1 output: fee = 10 + 21*148 + 1*34 + 60 = 3212 - // 53220 >= 50000 + 3212 -> succeeds, no change, fee = 3220 - let result = calculate_asset_lock_fee(53_220, 50_000, 21, false); - assert!( - result.is_ok(), - "should NOT fail with insufficient funds; got: {:?}", - result - ); - let result = result.unwrap(); - assert_eq!(result.actual_amount, 50_000); - assert_eq!(result.change, None); - // Fee absorbs the leftover: 53220 - 50000 = 3220 - assert_eq!(result.fee, 3_220); - } - - #[test] - fn fee_overestimate_when_change_disappears() { - // BUG TEST: Verify that when initial change exists under 3000 estimate but - // disappears under the real fee, we do not overcharge by counting the - // phantom change output. - // - // 21 inputs, amount=50000, total=53240. - // 2-output fee = 3246 -> 53240 < 53246, would fail with buggy code. - // 1-output fee = 3212 -> 53240 >= 53212, succeeds. fee = 3240, no change. - let result = calculate_asset_lock_fee(53_240, 50_000, 21, false); - assert!( - result.is_ok(), - "should succeed when recalculated without change output; got: {:?}", - result - ); - let result = result.unwrap(); - assert_eq!(result.actual_amount, 50_000); - assert_eq!(result.change, None); - // The fee with 2 outputs (3246) would be wrong here; only 1 output is needed. - // Actual fee = leftover = 53240 - 50000 = 3240, which is >= 1-output minimum (3212). - assert!( - result.fee < 3_246, - "fee should be less than the 2-output estimate of 3246, got {}", - result.fee - ); - assert_eq!(result.fee, 3_240); - } - #[test] - fn fee_dust_change_absorbed_into_fee() { - // 1 input, amount=46500, total=50000. - // With 2 outputs: fee = 3000. change = 50000 - 46500 - 3000 = 500. - // 500 < DUST_THRESHOLD (546) → change is dust, absorbed into fee. - // Falls through to 1-output path: fee = 3000. - // 50000 >= 46500 + 3000 → fee = 50000 - 46500 = 3500 (absorbs dust). - let result = calculate_asset_lock_fee(50_000, 46_500, 1, false).expect("should succeed"); - assert_eq!(result.actual_amount, 46_500); - assert_eq!( - result.change, None, - "dust change should not create an output" - ); - assert_eq!(result.fee, 3_500, "dust should be absorbed into fee"); - } -} diff --git a/src/model/wallet/mod.rs b/src/model/wallet/mod.rs index 6646ff1d8..7efc52a7a 100644 --- a/src/model/wallet/mod.rs +++ b/src/model/wallet/mod.rs @@ -1,4 +1,3 @@ -mod asset_lock_transaction; pub mod encryption; pub mod shielded; pub mod single_key; @@ -8,25 +7,17 @@ use crate::backend_task::error::TaskError; use crate::database::{Database, WalletError}; use crate::model::secret::Secret; use dash_sdk::dpp::ProtocolError; -use dash_sdk::dpp::address_funds::{AddressWitness, PlatformAddress}; -use dash_sdk::dpp::identity::signer::Signer; -use dash_sdk::dpp::key_wallet::account::AccountType; +use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::key_wallet::bip32::{ ChildNumber, DerivationPath, ExtendedPrivKey, ExtendedPubKey, KeyDerivationType, }; -use dash_sdk::dpp::key_wallet::psbt::serialize::Serialize; -use dash_sdk::dpp::prelude::AddressNonce; use dash_sdk::platform::address_sync::{AddressFunds, AddressIndex, AddressKey, AddressProvider}; -use dash_sdk::dpp::dashcore::secp256k1::{Message, Secp256k1}; -use dash_sdk::dpp::dashcore::sighash::SighashCache; +use dash_sdk::dpp::dashcore::secp256k1::Secp256k1; use dash_sdk::dpp::dashcore::{ - Address, BlockHash, InstantLock, Network, OutPoint, PrivateKey, PublicKey, ScriptBuf, - Transaction, TxIn, TxOut, Txid, + Address, BlockHash, Network, OutPoint, PrivateKey, PublicKey, Transaction, TxOut, Txid, }; -use dash_sdk::dpp::platform_value::BinaryData; -use std::cmp; -use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Debug; use std::ops::Range; use std::sync::{Arc, RwLock}; @@ -255,25 +246,8 @@ use crate::context::AppContext; use bitflags::bitflags; use dash_sdk::dpp::balances::credits::Duffs; use dash_sdk::dpp::dashcore::hashes::Hash; -use dash_sdk::dpp::fee::Credits; -use dash_sdk::dpp::prelude::AssetLockProof; -use dash_sdk::platform::Identity; use zeroize::Zeroize; -const BOOTSTRAP_BIP44_EXTERNAL_COUNT: u32 = 32; -const BOOTSTRAP_BIP44_CHANGE_COUNT: u32 = 16; -const BOOTSTRAP_BIP32_ACCOUNT_COUNT: u32 = 1; -const BOOTSTRAP_BIP32_ADDRESS_COUNT: u32 = 16; -const BOOTSTRAP_COINJOIN_ACCOUNT_COUNT: u32 = 1; -const BOOTSTRAP_COINJOIN_ADDRESS_COUNT: u32 = 16; -const BOOTSTRAP_IDENTITY_REGISTRATION_FALLBACK: u32 = 8; -const BOOTSTRAP_IDENTITY_INVITATION_COUNT: u32 = 8; -const BOOTSTRAP_IDENTITY_TOPUP_PER_REGISTRATION: u32 = 4; -const BOOTSTRAP_IDENTITY_TOPUP_NOT_BOUND_COUNT: u32 = 8; -const BOOTSTRAP_PROVIDER_ADDRESS_COUNT: u32 = 4; -/// DIP-17: Number of Platform payment addresses to bootstrap per key class -const BOOTSTRAP_PLATFORM_PAYMENT_ADDRESS_COUNT: u32 = 20; - bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)] pub struct DerivationPathType: u32 { @@ -297,13 +271,6 @@ bitflags! { | Self::DASHPAY.bits(); } } -#[derive(Debug, Clone, PartialEq)] -pub struct AddressInfo { - pub address: Address, - pub path_type: DerivationPathType, - pub path_reference: DerivationPathReference, -} - #[derive(Debug, Clone)] pub struct WalletArcRef { pub wallet: Arc>, @@ -330,44 +297,16 @@ impl PartialEq for WalletArcRef { } } -/// Information about a Platform address balance and nonce -#[derive(Debug, Clone, PartialEq, Default)] -pub struct PlatformAddressInfo { - pub balance: Credits, - pub nonce: AddressNonce, -} - -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone)] pub struct Wallet { + /// The platform wallet — `None` when the wallet is locked (encrypted seed). + /// Set on creation (open wallet) or on unlock. + pub platform_wallet: Option>, pub wallet_seed: WalletSeed, pub uses_password: bool, pub master_bip44_ecdsa_extended_public_key: ExtendedPubKey, - pub address_balances: BTreeMap, - /// Historical total received per address (not just current UTXOs) - pub address_total_received: BTreeMap, - pub known_addresses: BTreeMap, - pub watched_addresses: BTreeMap, - #[allow(clippy::type_complexity)] - pub unused_asset_locks: Vec<( - Transaction, - Address, - Credits, - Option, - Option, - )>, pub alias: Option, - pub identities: HashMap, - pub utxos: HashMap>, - pub transactions: Vec, pub is_main: bool, - 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 pub core_wallet_name: Option, } @@ -415,12 +354,10 @@ impl Wallet { let master_bip44_ecdsa_extended_public_key = ExtendedPubKey::from_priv(&secp, &account_priv); - // 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 { source: e.into() })?; + // Addresses are managed by PlatformWallet (created in register_wallet). Ok(Wallet { + platform_wallet: None, wallet_seed: WalletSeed::Open(OpenWalletSeed { seed, wallet_info: ClosedKeyItem { @@ -433,21 +370,8 @@ impl Wallet { }), uses_password, master_bip44_ecdsa_extended_public_key, - address_balances: Default::default(), - address_total_received: Default::default(), - known_addresses, - watched_addresses, - unused_asset_locks: Default::default(), alias, - identities: Default::default(), - utxos: Default::default(), - transactions: Vec::new(), is_main: true, - confirmed_balance: 0, - unconfirmed_balance: 0, - total_balance: 0, - spv_balance_known: false, - platform_address_info: Default::default(), core_wallet_name: None, }) } @@ -459,62 +383,6 @@ impl Wallet { _ => DerivationPath::from(DASH_BIP44_ACCOUNT_0_PATH_TESTNET.as_slice()), } } - - /// Derive the first receive address (index 0) and return populated - /// `known_addresses` and `watched_addresses` maps. - #[allow(clippy::type_complexity)] - fn derive_first_address( - master_pub: &ExtendedPubKey, - network: Network, - secp: &Secp256k1, - ) -> Result< - ( - BTreeMap, - BTreeMap, - ), - String, - > { - let mut known_addresses = BTreeMap::new(); - let mut watched_addresses = BTreeMap::new(); - - let address_path = DerivationPath::from( - [ - ChildNumber::Normal { index: 0 }, // receive (not change) - ChildNumber::Normal { index: 0 }, // first address - ] - .as_slice(), - ); - - let pk = master_pub - .derive_pub(secp, &address_path) - .map_err(|e| format!("Failed to derive first receive address: {e}"))?; - let address = Address::p2pkh(&pk.to_pub(), network); - let bip44 = match network { - Network::Mainnet => &DASH_BIP44_ACCOUNT_0_PATH_MAINNET, - _ => &DASH_BIP44_ACCOUNT_0_PATH_TESTNET, - }; - let full_path = DerivationPath::from( - [ - bip44[0], - bip44[1], - bip44[2], - ChildNumber::Normal { index: 0 }, - ChildNumber::Normal { index: 0 }, - ] - .as_slice(), - ); - known_addresses.insert(address.clone(), full_path.clone()); - watched_addresses.insert( - full_path, - AddressInfo { - address, - path_type: DerivationPathType::CLEAR_FUNDS, - path_reference: DerivationPathReference::BIP44, - }, - ); - - Ok((known_addresses, watched_addresses)) - } } /// Transaction lifecycle status. @@ -735,94 +603,139 @@ impl Wallet { matches!(self.wallet_seed, WalletSeed::Open(_)) } pub fn has_balance(&self) -> bool { - self.confirmed_balance_duffs() > 0 || self.unconfirmed_balance > 0 - } - - pub fn has_unused_asset_lock(&self) -> bool { - !self.unused_asset_locks.is_empty() - } - - pub fn max_balance(&self) -> u64 { - self.utxos - .values() - .flat_map(|outpoints_to_tx_out| outpoints_to_tx_out.values().map(|tx_out| tx_out.value)) - .sum::() + self.platform_wallet + .as_ref() + .map(|pw| pw.core().balance().total() > 0) + .unwrap_or(false) + } + + /// Checks whether this wallet has any unused asset locks in the database + /// (where identity_id IS NULL and identity_id_potentially_in_creation IS NULL). + pub fn has_unused_asset_lock(&self, db: &Database, network: Network) -> bool { + db.has_unused_asset_lock_transactions(&self.seed_hash(), network) + .unwrap_or(false) + } + + /// All addresses from PlatformWallet's CoreAddressInfo. + /// Returns empty if the wallet is locked (no PlatformWallet). + pub fn all_addresses_info(&self) -> Vec { + self.platform_wallet + .as_ref() + .map(|pw| { + let info = pw.state_blocking(); + crate::platform_wallet_bridge::CoreAddressInfo::all_from_wallet_info(info.managed_state.wallet_info()) + }) + .unwrap_or_default() + } + + /// Look up the derivation path for an address via PlatformWallet. + /// Returns `None` if the wallet is locked (no PlatformWallet). + pub fn derivation_path_for_address(&self, address: &Address) -> Option { + let pw = self.platform_wallet.as_ref()?; + // Use try_state() to avoid panic when called from async context + // (state_blocking panics inside tokio runtime). + // Returns None if the lock is held, which is safe — the caller retries next cycle. + let info = pw.try_state()?; + crate::platform_wallet_bridge::CoreAddressInfo::all_from_wallet_info(info.managed_state.wallet_info()) + .into_iter() + .find(|a| &a.address == address) + .map(|a| a.derivation_path) + } + + /// Async version of `derivation_path_for_address` — safe to call from async context. + pub async fn derivation_path_for_address_async(&self, address: &Address) -> Option { + let pw = self.platform_wallet.as_ref()?; + let info = pw.state().await; + crate::platform_wallet_bridge::CoreAddressInfo::all_from_wallet_info(info.managed_state.wallet_info()) + .into_iter() + .find(|a| &a.address == address) + .map(|a| a.derivation_path) + } + + /// Check if an address belongs to this wallet via PlatformWallet. + /// Returns `false` if the wallet is locked (no PlatformWallet). + pub fn has_address(&self, address: &Address) -> bool { + self.derivation_path_for_address(address).is_some() + } + + /// Async version of `has_address` — safe to call from async context. + pub async fn has_address_async(&self, address: &Address) -> bool { + self.derivation_path_for_address_async(address).await.is_some() + } + + /// Per-address balance from PlatformWallet's CoreAddressInfo. + pub fn address_balance(&self, address: &Address) -> u64 { + self.platform_wallet + .as_ref() + .map(|pw| { + let info = pw.state_blocking(); + crate::platform_wallet_bridge::CoreAddressInfo::all_from_wallet_info(info.managed_state.wallet_info()) + .into_iter() + .find(|a| &a.address == address) + .map(|a| a.balance) + .unwrap_or(0) + }) + .unwrap_or(0) } pub fn confirmed_balance_duffs(&self) -> u64 { - if self.total_balance > 0 || self.confirmed_balance > 0 || self.unconfirmed_balance > 0 { - self.confirmed_balance - } else { - self.max_balance() - } + self.platform_wallet + .as_ref() + .map(|pw| pw.core().balance().spendable()) + .unwrap_or(0) } - /// Returns the SPV-reported confirmed balance, or `None` if SPV hasn't - /// synced balance data yet. Unlike `confirmed_balance_duffs()`, this - /// never falls back to `max_balance()` — callers that need certainty + /// Returns the SPV-reported confirmed balance, or `None` if the platform + /// wallet is not available (locked). Callers that need certainty /// (e.g., test waiters) should use this and retry on `None`. pub fn spv_confirmed_balance(&self) -> Option { - if self.spv_balance_known { - Some(self.confirmed_balance) - } else { - None - } + self.platform_wallet + .as_ref() + .map(|pw| pw.core().balance().spendable()) } pub fn unconfirmed_balance_duffs(&self) -> u64 { - self.unconfirmed_balance + self.platform_wallet + .as_ref() + .map(|pw| pw.core().balance().unconfirmed()) + .unwrap_or(0) } pub fn total_balance_duffs(&self) -> u64 { - if self.total_balance > 0 { - self.total_balance - } else { - self.max_balance() - } - } - - pub fn update_spv_balances(&mut self, confirmed: u64, unconfirmed: u64, total: u64) { - 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) { - if !self.is_open() { - tracing::debug!("Skipping address bootstrap for locked wallet"); - return; - } - - let network = app_context.network; - - if let Err(err) = self.bootstrap_bip44_addresses(network, app_context) { - tracing::warn!("Failed to bootstrap BIP44 addresses: {}", err); - } - - if let Err(err) = self.bootstrap_bip32_addresses(network, app_context) { - tracing::warn!("Failed to bootstrap BIP32 addresses: {}", err); - } - - if let Err(err) = self.bootstrap_coinjoin_addresses(network, app_context) { - tracing::warn!("Failed to bootstrap CoinJoin addresses: {}", err); - } - - if let Err(err) = self.bootstrap_identity_addresses(network, app_context) { - tracing::warn!("Failed to bootstrap identity addresses: {}", err); - } - - if let Err(err) = self.bootstrap_provider_addresses(network, app_context) { - tracing::warn!("Failed to bootstrap provider addresses: {}", err); - } - - if let Err(err) = self.bootstrap_platform_payment_addresses(network, app_context) { - tracing::warn!("Failed to bootstrap Platform payment addresses: {}", err); - } + self.platform_wallet + .as_ref() + .map(|pw| pw.core().balance().total()) + .unwrap_or(0) } - pub fn set_transactions(&mut self, transactions: Vec) { - self.transactions = transactions; + /// Read transaction history from the PlatformWallet's ManagedWalletInfo. + /// + /// Returns an empty vec when the wallet is locked (no PlatformWallet). + pub fn get_transactions(&self) -> Vec { + let Some(pw) = self.platform_wallet.as_ref() else { + return Vec::new(); + }; + let info = pw.state_blocking(); + info.managed_state.wallet_info().transaction_history() + .into_iter() + .map(|record| { + let height = record.height(); + let status = TransactionStatus::from_height(height); + let block_info = record.context.block_info(); + WalletTransaction { + txid: record.txid, + transaction: record.transaction.clone(), + timestamp: block_info.map(|bi| bi.timestamp() as u64).unwrap_or(0), + height, + block_hash: block_info.map(|bi| bi.block_hash()), + net_amount: record.net_amount, + fee: record.fee, + label: record.label.clone(), + is_ours: true, + status, + } + }) + .collect() } pub(crate) fn seed_bytes(&self) -> Result<&[u8; 64], String> { @@ -925,8 +838,7 @@ impl Wallet { address: &Address, network: Network, ) -> Result, String> { - self.known_addresses - .get(address) + self.derivation_path_for_address(address) .map(|derivation_path| { derivation_path .derive_priv_ecdsa_for_master_seed(self.seed_bytes()?, network) @@ -936,97 +848,6 @@ impl Wallet { .transpose() } - pub fn unused_bip_44_public_key( - &mut self, - network: Network, - skip_known_addresses_with_no_funds: bool, - change: bool, - register: Option<&AppContext>, - ) -> Result<(PublicKey, DerivationPath), String> { - let mut address_index = 0; - let mut found_unused_derivation_path = None; - let mut known_public_key = None; - while found_unused_derivation_path.is_none() { - let derivation_path_extension = DerivationPath::from( - [ - ChildNumber::Normal { - index: change.into(), - }, - ChildNumber::Normal { - index: address_index, - }, - ] - .as_slice(), - ); - let derivation_path = - DerivationPath::bip_44_payment_path(network, 0, change, address_index); - - if let Some(address_info) = self.watched_addresses.get(&derivation_path) { - // Address is known - let address = &address_info.address; - let balance = self.address_balances.get(address).cloned().unwrap_or(0); - - if balance > 0 { - // Address has funds, skip it - address_index += 1; - continue; - } - - // Address is known and has zero balance - if !skip_known_addresses_with_no_funds { - // We can use this address - found_unused_derivation_path = Some(derivation_path.clone()); - let secp = Secp256k1::new(); - let public_key = self - .master_bip44_ecdsa_extended_public_key - .derive_pub(&secp, &derivation_path_extension) - .map_err(|e| WalletError::KeyDerivation { source: e }.to_string())? - .to_pub(); - known_public_key = Some(public_key); - break; - } else { - // Skip known addresses with no funds - address_index += 1; - continue; - } - } else { - let secp = Secp256k1::new(); - let public_key = self - .master_bip44_ecdsa_extended_public_key - .derive_pub(&secp, &derivation_path_extension) - .map_err(|e| WalletError::KeyDerivation { source: e }.to_string())? - .to_pub(); - known_public_key = Some(public_key); - if let Some(app_context) = register { - let address = Address::p2pkh(&public_key, network); - app_context.try_import_address( - &address, - self.core_wallet_name.as_deref(), - Some(&format!( - "Managed by Dash Evo Tool {} {}", - self.alias.clone().unwrap_or_default(), - derivation_path - )), - ); - - self.register_address( - address, - &derivation_path, - DerivationPathType::CLEAR_FUNDS, - DerivationPathReference::BIP44, - app_context, - )?; - } - found_unused_derivation_path = Some(derivation_path.clone()); - break; - } - } - - let derivation_path = found_unused_derivation_path.unwrap(); - let known_public_key = known_public_key.unwrap(); - Ok((known_public_key, derivation_path)) - } - pub fn identity_authentication_ecdsa_public_key( &self, network: Network, @@ -1187,16 +1008,6 @@ impl Wallet { None, ) .map_err(|e| e.to_string())?; - self.known_addresses - .insert(address.clone(), derivation_path.clone()); - self.watched_addresses.insert( - derivation_path.clone(), - AddressInfo { - address: address.clone(), - path_type, - path_reference, - }, - ); if app_context.core_backend_mode() == crate::spv::CoreBackendMode::Rpc { app_context.try_import_address(&address, self.core_wallet_name.as_deref(), None); @@ -1210,368 +1021,6 @@ impl Wallet { Ok(()) } - fn bootstrap_bip44_addresses( - &mut self, - network: Network, - app_context: &AppContext, - ) -> Result<(), String> { - let coin_type = Self::coin_type(network); - let secp = Secp256k1::new(); - for (change_flag, max) in [ - (false, BOOTSTRAP_BIP44_EXTERNAL_COUNT), - (true, BOOTSTRAP_BIP44_CHANGE_COUNT), - ] { - for index in 0..max { - let child_path = [ - ChildNumber::Normal { - index: change_flag as u32, - }, - ChildNumber::Normal { index }, - ]; - let derived = self - .master_bip44_ecdsa_extended_public_key - .derive_pub(&secp, &child_path) - .map_err(|e| WalletError::KeyDerivation { source: e }.to_string())?; - let dash_public_key = PublicKey::from_slice(&derived.public_key.serialize()) - .map_err(|e| e.to_string())?; - let derivation_path = DerivationPath::from(vec![ - ChildNumber::Hardened { index: 44 }, - ChildNumber::Hardened { index: coin_type }, - ChildNumber::Hardened { index: 0 }, - ChildNumber::Normal { - index: change_flag as u32, - }, - ChildNumber::Normal { index }, - ]); - self.register_address_from_public_key( - &dash_public_key, - &derivation_path, - DerivationPathType::CLEAR_FUNDS, - DerivationPathReference::BIP44, - app_context, - )?; - } - } - Ok(()) - } - - fn bootstrap_bip32_addresses( - &mut self, - network: Network, - app_context: &AppContext, - ) -> Result<(), String> { - let seed = *self.seed_bytes()?; - for account in 0..BOOTSTRAP_BIP32_ACCOUNT_COUNT { - for index in 0..BOOTSTRAP_BIP32_ADDRESS_COUNT { - let derivation_path = DerivationPath::from(vec![ - ChildNumber::Hardened { index: account }, - ChildNumber::Normal { index }, - ]); - let extended_private_key = derivation_path - .derive_priv_ecdsa_for_master_seed(&seed, network) - .map_err(|e| WalletError::KeyDerivation { source: e }.to_string())?; - let private_key = extended_private_key.to_priv(); - self.register_address_from_private_key( - &private_key, - &derivation_path, - DerivationPathType::CLEAR_FUNDS, - DerivationPathReference::BIP32, - app_context, - )?; - } - } - Ok(()) - } - - fn bootstrap_coinjoin_addresses( - &mut self, - network: Network, - app_context: &AppContext, - ) -> Result<(), String> { - let seed = *self.seed_bytes()?; - for account in 0..BOOTSTRAP_COINJOIN_ACCOUNT_COUNT { - let base_path = DerivationPath::coinjoin_path(network, account); - for index in 0..BOOTSTRAP_COINJOIN_ADDRESS_COUNT { - let mut components = base_path.as_ref().to_vec(); - components.push(ChildNumber::Normal { index }); - let derivation_path = DerivationPath::from(components); - let extended_private_key = derivation_path - .derive_priv_ecdsa_for_master_seed(&seed, network) - .map_err(|e| WalletError::KeyDerivation { source: e }.to_string())?; - let private_key = extended_private_key.to_priv(); - self.register_address_from_private_key( - &private_key, - &derivation_path, - DerivationPathType::ANONYMOUS_FUNDS, - DerivationPathReference::ProviderFunds, - app_context, - )?; - } - } - Ok(()) - } - - fn bootstrap_identity_addresses( - &mut self, - network: Network, - app_context: &AppContext, - ) -> Result<(), String> { - let registration_indices = self.identity_registration_indices(); - self.bootstrap_identity_registration_addresses( - network, - app_context, - ®istration_indices, - )?; - self.bootstrap_identity_invitation_addresses(network, app_context)?; - self.bootstrap_identity_topup_addresses(network, app_context, ®istration_indices)?; - Ok(()) - } - - fn bootstrap_identity_registration_addresses( - &mut self, - network: Network, - app_context: &AppContext, - registration_indices: &BTreeSet, - ) -> Result<(), String> { - let seed = *self.seed_bytes()?; - for &index in registration_indices { - let derivation_path = DerivationPath::identity_registration_path(network, index); - let extended_private_key = derivation_path - .derive_priv_ecdsa_for_master_seed(&seed, network) - .map_err(|e| WalletError::KeyDerivation { source: e }.to_string())?; - let private_key = extended_private_key.to_priv(); - self.register_address_from_private_key( - &private_key, - &derivation_path, - DerivationPathType::CREDIT_FUNDING, - DerivationPathReference::BlockchainIdentityCreditRegistrationFunding, - app_context, - )?; - } - Ok(()) - } - - fn bootstrap_identity_invitation_addresses( - &mut self, - network: Network, - app_context: &AppContext, - ) -> Result<(), String> { - let seed = *self.seed_bytes()?; - for index in 0..BOOTSTRAP_IDENTITY_INVITATION_COUNT { - let derivation_path = DerivationPath::identity_invitation_path(network, index); - let extended_private_key = derivation_path - .derive_priv_ecdsa_for_master_seed(&seed, network) - .map_err(|e| WalletError::KeyDerivation { source: e }.to_string())?; - let private_key = extended_private_key.to_priv(); - self.register_address_from_private_key( - &private_key, - &derivation_path, - DerivationPathType::CREDIT_FUNDING, - DerivationPathReference::BlockchainIdentityCreditInvitationFunding, - app_context, - )?; - } - Ok(()) - } - - fn bootstrap_identity_topup_addresses( - &mut self, - network: Network, - app_context: &AppContext, - registration_indices: &BTreeSet, - ) -> Result<(), String> { - let seed = *self.seed_bytes()?; - for ®istration_index in registration_indices { - for top_up_index in 0..BOOTSTRAP_IDENTITY_TOPUP_PER_REGISTRATION { - let derivation_path = - DerivationPath::identity_top_up_path(network, registration_index, top_up_index); - let extended_private_key = derivation_path - .derive_priv_ecdsa_for_master_seed(&seed, network) - .map_err(|e| WalletError::KeyDerivation { source: e }.to_string())?; - let private_key = extended_private_key.to_priv(); - self.register_address_from_private_key( - &private_key, - &derivation_path, - DerivationPathType::CREDIT_FUNDING, - DerivationPathReference::BlockchainIdentityCreditTopupFunding, - app_context, - )?; - } - } - self.bootstrap_identity_topup_not_bound_addresses(network, app_context, &seed) - } - - fn bootstrap_identity_topup_not_bound_addresses( - &mut self, - network: Network, - app_context: &AppContext, - seed: &[u8; 64], - ) -> Result<(), String> { - let base_path = AccountType::IdentityTopUpNotBoundToIdentity - .derivation_path(network) - .map_err(|e| e.to_string())?; - for index in 0..BOOTSTRAP_IDENTITY_TOPUP_NOT_BOUND_COUNT { - let mut components = base_path.as_ref().to_vec(); - components.push(ChildNumber::Normal { index }); - let derivation_path = DerivationPath::from(components); - let extended_private_key = derivation_path - .derive_priv_ecdsa_for_master_seed(seed, network) - .map_err(|e| WalletError::KeyDerivation { source: e }.to_string())?; - let private_key = extended_private_key.to_priv(); - self.register_address_from_private_key( - &private_key, - &derivation_path, - DerivationPathType::CREDIT_FUNDING, - DerivationPathReference::BlockchainIdentityCreditTopupFunding, - app_context, - )?; - } - Ok(()) - } - - fn identity_registration_indices(&self) -> BTreeSet { - let mut indices: BTreeSet = self.identities.keys().copied().collect(); - let fallback_limit = BOOTSTRAP_IDENTITY_REGISTRATION_FALLBACK; - let max_existing = indices.iter().copied().max().unwrap_or(0); - let target = cmp::max(max_existing.saturating_add(2), fallback_limit); - indices.extend(0..target); - indices - } - - fn bootstrap_provider_addresses( - &mut self, - network: Network, - app_context: &AppContext, - ) -> Result<(), String> { - self.bootstrap_provider_account(network, app_context, AccountType::ProviderVotingKeys)?; - self.bootstrap_provider_account(network, app_context, AccountType::ProviderOwnerKeys)?; - Ok(()) - } - - fn bootstrap_provider_account( - &mut self, - network: Network, - app_context: &AppContext, - account_type: AccountType, - ) -> Result<(), String> { - let seed = *self.seed_bytes()?; - let base_path = account_type - .derivation_path(network) - .map_err(|e| e.to_string())?; - let key_wallet_reference = account_type.derivation_path_reference(); - let path_reference = DerivationPathReference::try_from(key_wallet_reference as u32) - .unwrap_or(DerivationPathReference::Unknown); - for provider_index in 0..BOOTSTRAP_PROVIDER_ADDRESS_COUNT { - let mut components = base_path.as_ref().to_vec(); - components.push(ChildNumber::Hardened { - index: provider_index, - }); - let derivation_path = DerivationPath::from(components); - let extended_private_key = derivation_path - .derive_priv_ecdsa_for_master_seed(&seed, network) - .map_err(|e| WalletError::KeyDerivation { source: e }.to_string())?; - let private_key = extended_private_key.to_priv(); - self.register_address_from_private_key( - &private_key, - &derivation_path, - DerivationPathType::CLEAR_FUNDS, - path_reference, - app_context, - )?; - } - Ok(()) - } - - /// Bootstrap DIP-17 Platform payment addresses (dash/tdash Bech32m HRP per DIP-18) - /// These addresses are for receiving Dash Credits on Platform, independent of identities. - fn bootstrap_platform_payment_addresses( - &mut self, - network: Network, - app_context: &AppContext, - ) -> Result<(), String> { - let seed = *self.seed_bytes()?; - // Default account 0', default key_class 0' (as per DIP-17) - let account = 0u32; - let key_class = 0u32; - - for index in 0..BOOTSTRAP_PLATFORM_PAYMENT_ADDRESS_COUNT { - let derivation_path = - DerivationPath::platform_payment_path(network, account, key_class, index); - let extended_private_key = derivation_path - .derive_priv_ecdsa_for_master_seed(&seed, network) - .map_err(|e| WalletError::KeyDerivation { source: e }.to_string())?; - let private_key = extended_private_key.to_priv(); - - // Create a P2PKH address for platform payment - let secp = Secp256k1::new(); - let public_key = private_key.public_key(&secp); - let platform_address = Address::p2pkh(&public_key, network); - - // Register the Platform address - self.register_platform_address( - platform_address, - &derivation_path, - DerivationPathType::CLEAR_FUNDS, - DerivationPathReference::PlatformPayment, - app_context, - )?; - } - Ok(()) - } - - /// Register a Platform payment address (DIP-17/18). - /// Platform addresses use different version bytes and are NOT valid on Core chain. - fn register_platform_address( - &mut self, - address: Address, - derivation_path: &DerivationPath, - path_type: DerivationPathType, - path_reference: DerivationPathReference, - app_context: &AppContext, - ) -> Result<(), String> { - let canonical_address = Wallet::canonical_address(&address, app_context.network); - - // Store the address in known_addresses and watched_addresses - // Note: We don't import to Core wallet since Platform addresses are not valid there - app_context - .db - .add_address_if_not_exists( - &self.seed_hash(), - &canonical_address, - &app_context.network, - derivation_path, - path_reference, - path_type, - None, - ) - .map_err(|e| e.to_string())?; - - self.known_addresses - .insert(canonical_address.clone(), derivation_path.clone()); - self.watched_addresses.insert( - derivation_path.clone(), - AddressInfo { - address: canonical_address.clone(), - path_type, - path_reference, - }, - ); - - tracing::trace!( - address = ?&address, - network = &app_context.network.to_string(), - "registered new Platform payment address" - ); - Ok(()) - } - - fn coin_type(network: Network) -> u32 { - match network { - Network::Mainnet => 5, - _ => 1, - } - } - pub fn identity_top_up_ecdsa_private_key( &mut self, app_context: &AppContext, @@ -1621,144 +1070,31 @@ impl Wallet { pub fn receive_address( &mut self, - network: Network, - skip_known_addresses_with_no_funds: bool, - register: Option<&AppContext>, + _network: Network, + _force_new: bool, + _register: Option<&AppContext>, ) -> Result { - Ok(Address::p2pkh( - &self - .unused_bip_44_public_key( - network, - skip_known_addresses_with_no_funds, - false, - register, - )? - .0, - network, - )) - } - - // Allow dead_code: This method provides receive addresses with derivation paths, - // useful for advanced address management and BIP44 path tracking - #[allow(dead_code)] - pub fn receive_address_with_derivation_path( - &mut self, - network: Network, - register: Option<&AppContext>, - ) -> Result<(Address, DerivationPath), String> { - let (receive_public_key, derivation_path) = - self.unused_bip_44_public_key(network, false, false, register)?; - Ok(( - Address::p2pkh(&receive_public_key, network), - derivation_path, - )) + if let Some(pw) = &self.platform_wallet { + return pw + .core() + .next_receive_address_blocking() + .map_err(|e| e.to_string()); + } + Err("Wallet is locked".to_string()) } pub fn change_address( &mut self, - network: Network, - register: Option<&AppContext>, - ) -> Result { - Ok(Address::p2pkh( - &self - .unused_bip_44_public_key(network, false, true, register)? - .0, - network, - )) - } - - // Allow dead_code: This method provides change addresses with derivation paths, - // useful for advanced address management and BIP44 path tracking - #[allow(dead_code)] - pub fn change_address_with_derivation_path( - &mut self, - network: Network, - register: Option<&AppContext>, - ) -> Result<(Address, DerivationPath), String> { - let (receive_public_key, derivation_path) = - self.unused_bip_44_public_key(network, false, true, register)?; - Ok(( - Address::p2pkh(&receive_public_key, network), - derivation_path, - )) - } - - /// Generate a Platform receive address. - /// Either returns an existing Platform address or generates a new one. - pub fn platform_receive_address( - &mut self, - network: Network, - skip_known_addresses: bool, - register: Option<&AppContext>, + _network: Network, + _register: Option<&AppContext>, ) -> Result { - // If not skipping known addresses, return first existing one - // This doesn't require the wallet to be unlocked - if !skip_known_addresses { - for (path, info) in &self.watched_addresses { - if path.is_platform_payment(network) { - return Ok(info.address.clone()); - } - } + if let Some(pw) = &self.platform_wallet { + return pw + .core() + .next_change_address_blocking() + .map_err(|e| e.to_string()); } - - // Need to generate a new address - this requires the wallet to be unlocked - let seed = *self.seed_bytes()?; - let secp = Secp256k1::new(); - let account = 0u32; - let key_class = 0u32; - - // Find the highest index in existing Platform payment addresses - let existing_indices: Vec = self - .watched_addresses - .iter() - .filter(|(path, _)| path.is_platform_payment(network)) - .filter_map(|(path, _)| { - // Extract the index from the path (last component) - path.into_iter().last().and_then(|child| match child { - ChildNumber::Normal { index } | ChildNumber::Hardened { index } => Some(*index), - _ => None, - }) - }) - .collect(); - - // Generate a new Platform address at the next index - let next_index = existing_indices.iter().max().map(|m| m + 1).unwrap_or(0); - - let derivation_path = - DerivationPath::platform_payment_path(network, account, key_class, next_index); - let extended_private_key = derivation_path - .derive_priv_ecdsa_for_master_seed(&seed, network) - .map_err(|e| WalletError::KeyDerivation { source: e }.to_string())?; - let private_key = extended_private_key.to_priv(); - let public_key = private_key.public_key(&secp); - - // Create a P2PKH address for platform payment - let platform_address = Address::p2pkh(&public_key, network); - - // Register the new address - if let Some(app_context) = register { - self.register_platform_address( - platform_address.clone(), - &derivation_path, - DerivationPathType::CLEAR_FUNDS, - DerivationPathReference::PlatformPayment, - app_context, - )?; - } else { - // Just update local state without persisting - self.known_addresses - .insert(platform_address.clone(), derivation_path.clone()); - self.watched_addresses.insert( - derivation_path, - AddressInfo { - address: platform_address.clone(), - path_type: DerivationPathType::CLEAR_FUNDS, - path_reference: DerivationPathReference::PlatformPayment, - }, - ); - } - - Ok(platform_address) + Err("Wallet is locked".to_string()) } pub fn derive_bip44_address( @@ -1784,327 +1120,20 @@ impl Wallet { Ok(Address::p2pkh(&public_key, network)) } - pub fn build_standard_payment_transaction( - &mut self, - app_context: &AppContext, - network: Network, - recipient: &Address, - amount: u64, - fee: u64, - subtract_fee_from_amount: bool, - ) -> Result { - if !networks_address_compatible(recipient.network(), &network) { - return Err(format!( - "Recipient address network ({}) does not match wallet network ({})", - recipient.network(), - network - )); - } - - // Select UTXOs without removing them yet — UTXOs are only removed after - // the transaction is fully built and signed, so that a failure at any later - // step cannot permanently drop UTXOs from the wallet. - let (utxos, change_option) = self - .select_unspent_utxos_for(amount, fee, subtract_fee_from_amount, None) - .ok_or_else(|| "Insufficient funds".to_string())?; - - let send_value = if change_option.is_none() && subtract_fee_from_amount { - let total_input: u64 = utxos.values().map(|(tx_out, _)| tx_out.value).sum(); - total_input - .checked_sub(fee) - .ok_or_else(|| "Fee exceeds available amount".to_string())? - } else { - amount - }; - - if send_value == 0 { - return Err("Amount is zero after subtracting fee".to_string()); - } - - let mut outputs = vec![TxOut { - value: send_value, - script_pubkey: recipient.script_pubkey(), - }]; - - if let Some(change) = change_option { - let change_address = self.change_address(network, Some(app_context))?; - outputs.push(TxOut { - value: change, - script_pubkey: change_address.script_pubkey(), - }); - } - - let mut tx = Transaction { - version: 2, - lock_time: 0, - input: utxos - .keys() - .map(|outpoint| TxIn { - previous_output: *outpoint, - ..Default::default() - }) - .collect(), - output: outputs, - special_transaction_payload: None, - }; - - let sighash_flag = 1u32; - let cache = SighashCache::new(&tx); - let sighashes: Vec<_> = tx - .input - .iter() - .enumerate() - .map(|(i, input)| { - let script_pubkey = utxos - .get(&input.previous_output) - .ok_or_else(|| { - format!("missing utxo for outpoint {:?}", input.previous_output) - })? - .0 - .script_pubkey - .clone(); - cache - .legacy_signature_hash(i, &script_pubkey, sighash_flag) - .map_err(|source| { - WalletError::Sighash { - input_index: i, - source, - } - .to_string() - }) - }) - .collect::, String>>()?; - - let secp = Secp256k1::new(); - let mut utxo_lookup = utxos.clone(); - - tx.input - .iter_mut() - .zip(sighashes.into_iter()) - .try_for_each(|(input, sighash)| { - let (_, input_address) = - utxo_lookup.remove(&input.previous_output).ok_or_else(|| { - format!("utxo missing for outpoint {:?}", input.previous_output) - })?; - let private_key = self - .private_key_for_address(&input_address, network)? - .ok_or_else(|| format!("Address {} not managed by wallet", input_address))?; - let message = Message::from_digest(sighash.into()); - let sig = secp.sign_ecdsa(&message, &private_key.inner); - let mut serialized_sig = sig.serialize_der().to_vec(); - let mut script_sig = vec![serialized_sig.len() as u8 + 1]; - script_sig.append(&mut serialized_sig); - script_sig.push(1); - let mut serialized_pub_key = private_key.public_key(&secp).serialize(); - script_sig.push(serialized_pub_key.len() as u8); - script_sig.append(&mut serialized_pub_key); - input.script_sig = ScriptBuf::from_bytes(script_sig); - Ok::<(), String>(()) - })?; - - // Transaction is fully built and signed; commit the UTXO removals now. - self.remove_selected_utxos(&utxos, &app_context.db, network)?; - - Ok(tx) - } - - /// Build a transaction with multiple recipients - pub fn build_multi_recipient_payment_transaction( - &mut self, - app_context: &AppContext, - network: Network, - recipients: &[(Address, u64)], - fee: u64, - subtract_fee_from_amount: bool, - ) -> Result { - if recipients.is_empty() { - return Err("No recipients specified".to_string()); - } - - // Validate all recipients are on the correct network - for (recipient, _) in recipients { - if !networks_address_compatible(recipient.network(), &network) { - return Err(format!( - "Recipient address network ({}) does not match wallet network ({})", - recipient.network(), - network - )); - } - } - - // Calculate total amount needed - let total_amount: u64 = recipients.iter().map(|(_, amount)| *amount).sum(); - - // Select UTXOs without removing them yet — UTXOs are only removed after - // the transaction is fully built and signed, so that a failure at any later - // step cannot permanently drop UTXOs from the wallet. - let (utxos, change_option) = self - .select_unspent_utxos_for(total_amount, fee, subtract_fee_from_amount, None) - .ok_or_else(|| "Insufficient funds".to_string())?; - - // Build outputs for each recipient - let mut outputs: Vec = if change_option.is_none() && subtract_fee_from_amount { - // If we're subtracting fee and using all funds, we need to reduce recipient amounts proportionally - let total_input: u64 = utxos.values().map(|(tx_out, _)| tx_out.value).sum(); - let available_after_fee = total_input - .checked_sub(fee) - .ok_or_else(|| "Fee exceeds available amount".to_string())?; - - // Distribute the reduction proportionally across recipients - let reduction_ratio = available_after_fee as f64 / total_amount as f64; - - recipients - .iter() - .map(|(recipient, amount)| { - let adjusted_amount = (*amount as f64 * reduction_ratio) as u64; - TxOut { - value: adjusted_amount, - script_pubkey: recipient.script_pubkey(), - } - }) - .collect() - } else { - recipients - .iter() - .map(|(recipient, amount)| TxOut { - value: *amount, - script_pubkey: recipient.script_pubkey(), - }) - .collect() - }; - - // Check that no output is zero - if outputs.iter().any(|o| o.value == 0) { - return Err("One or more amounts are zero after subtracting fee".to_string()); - } - - // Add change output if needed - if let Some(change) = change_option { - let change_address = self.change_address(network, Some(app_context))?; - outputs.push(TxOut { - value: change, - script_pubkey: change_address.script_pubkey(), - }); - } - - let mut tx = Transaction { - version: 2, - lock_time: 0, - input: utxos - .keys() - .map(|outpoint| TxIn { - previous_output: *outpoint, - ..Default::default() - }) - .collect(), - output: outputs, - special_transaction_payload: None, - }; - - let sighash_flag = 1u32; - let cache = SighashCache::new(&tx); - let sighashes: Vec<_> = tx - .input - .iter() - .enumerate() - .map(|(i, input)| { - let script_pubkey = utxos - .get(&input.previous_output) - .ok_or_else(|| { - format!("missing utxo for outpoint {:?}", input.previous_output) - })? - .0 - .script_pubkey - .clone(); - cache - .legacy_signature_hash(i, &script_pubkey, sighash_flag) - .map_err(|source| { - WalletError::Sighash { - input_index: i, - source, - } - .to_string() - }) - }) - .collect::, String>>()?; - - let secp = Secp256k1::new(); - let mut utxo_lookup = utxos.clone(); - - tx.input - .iter_mut() - .zip(sighashes.into_iter()) - .try_for_each(|(input, sighash)| { - let (_, input_address) = - utxo_lookup.remove(&input.previous_output).ok_or_else(|| { - format!("utxo missing for outpoint {:?}", input.previous_output) - })?; - let private_key = self - .private_key_for_address(&input_address, network)? - .ok_or_else(|| format!("Address {} not managed by wallet", input_address))?; - let message = Message::from_digest(sighash.into()); - let sig = secp.sign_ecdsa(&message, &private_key.inner); - let mut serialized_sig = sig.serialize_der().to_vec(); - let mut script_sig = vec![serialized_sig.len() as u8 + 1]; - script_sig.append(&mut serialized_sig); - script_sig.push(1); - let mut serialized_pub_key = private_key.public_key(&secp).serialize(); - script_sig.push(serialized_pub_key.len() as u8); - script_sig.append(&mut serialized_pub_key); - input.script_sig = ScriptBuf::from_bytes(script_sig); - Ok::<(), String>(()) - })?; - - // Transaction is fully built and signed; commit the UTXO removals now. - self.remove_selected_utxos(&utxos, &app_context.db, network)?; - - Ok(tx) - } - pub fn update_address_balance( - &mut self, + &self, address: &Address, new_balance: Duffs, context: &AppContext, ) -> Result<(), String> { - // Check if the new balance differs from the current one. - if let Some(current_balance) = self.address_balances.get(address) - && *current_balance == new_balance - { - // If the balance hasn't changed, skip the update. - return Ok(()); - } - - // If there's no current balance or it has changed, update it. - self.address_balances.insert(address.clone(), new_balance); - - // Update the database with the new balance. context .db .update_address_balance(&self.seed_hash(), address, new_balance) .map_err(|e| e.to_string()) } - /// Recalculate and persist balances for all addresses affected by spent UTXOs. - /// - /// Call this after removing entries from `self.utxos` to keep `address_balances` - /// and the database in sync. - pub fn recalculate_affected_address_balances( - &mut self, - used_utxos: &BTreeMap, - context: &AppContext, - ) -> Result<(), String> { - self.recalculate_affected_address_balances_with_db(used_utxos, &context.db) - } - - /// Core implementation: recalculate and persist balances for addresses affected - /// by spent UTXOs, using the database directly. - /// - /// Prefer [`Self::recalculate_affected_address_balances`] when an `AppContext` - /// is available. This variant is used by [`Self::remove_selected_utxos`] which - /// already receives `&Database` directly. fn recalculate_affected_address_balances_with_db( - &mut self, + &self, used_utxos: &BTreeMap, db: &Database, ) -> Result<(), String> { @@ -2112,147 +1141,25 @@ impl Wallet { let affected_addresses: BTreeSet<_> = used_utxos.values().map(|(_, addr)| addr.clone()).collect(); for address in affected_addresses { - let new_balance: u64 = self - .utxos - .get(&address) - .map(|utxo_map| utxo_map.values().map(|tx_out| tx_out.value).sum()) - .unwrap_or(0); - if let Some(current) = self.address_balances.get(&address) - && *current == new_balance - { - continue; - } - self.address_balances.insert(address.clone(), new_balance); + let new_balance = self.address_balance(&address); db.update_address_balance(&seed_hash, &address, new_balance) .map_err(|e| e.to_string())?; } Ok(()) } - /// Recalculate and persist the balance for a single address from its remaining UTXOs. - pub fn recalculate_address_balance( - &mut self, - address: &Address, - context: &AppContext, - ) -> Result<(), String> { - let new_balance = self - .utxos - .get(address) - .map(|utxo_map| utxo_map.values().map(|tx_out| tx_out.value).sum()) - .unwrap_or(0); - self.update_address_balance(address, new_balance, context) - } - pub fn update_address_total_received( - &mut self, + &self, address: &Address, total_received: Duffs, context: &AppContext, ) -> Result<(), String> { - // Check if the total received differs from the current value - if let Some(current_total) = self.address_total_received.get(address) - && *current_total == total_received - { - // If the total received hasn't changed, skip the update. - return Ok(()); - } - - // Update in memory - self.address_total_received - .insert(address.clone(), total_received); - - // Update the database context .db .update_address_total_received(&self.seed_hash(), address, total_received) .map_err(|e| e.to_string()) } - /// Get all Platform payment addresses from this wallet - pub fn platform_addresses(&self, network: Network) -> Vec<(Address, PlatformAddress)> { - self.watched_addresses - .iter() - .filter(|(path, _)| path.is_platform_payment(network)) - .filter_map(|(_, info)| { - PlatformAddress::try_from(info.address.clone()) - .ok() - .map(|platform_addr| (info.address.clone(), platform_addr)) - }) - .collect() - } - - /// Get the total Platform balance (sum of all Platform address balances) - pub fn total_platform_balance(&self) -> Credits { - self.platform_address_info - .values() - .map(|info| info.balance) - .sum() - } - - /// Get Platform address info by canonical address comparison. - /// - /// This method handles the case where the same platform address may be represented - /// by different Address objects. It normalizes by comparing PlatformAddress bytes - /// to find a matching entry. - pub fn get_platform_address_info(&self, address: &Address) -> Option<&PlatformAddressInfo> { - // First try direct lookup - if let Some(info) = self.platform_address_info.get(address) { - return Some(info); - } - - // If direct lookup fails, try canonical comparison via PlatformAddress bytes - if let Ok(platform_addr) = PlatformAddress::try_from(address.clone()) { - let canonical_bytes = platform_addr.to_bytes(); - for (existing_addr, info) in &self.platform_address_info { - if let Ok(existing_platform) = PlatformAddress::try_from(existing_addr.clone()) - && existing_platform.to_bytes() == canonical_bytes - { - return Some(info); - } - } - } - - None - } - - /// Update Platform address info (balance and nonce). - /// - /// 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, - ) { - // 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(); - - for key in keys_to_remove { - self.platform_address_info.remove(&key); - } - } - - self.platform_address_info - .insert(address, PlatformAddressInfo { balance, nonce }); - } - /// Get the private key for a Platform address #[allow(clippy::result_large_err)] pub fn get_platform_address_private_key( @@ -2260,19 +1167,12 @@ impl Wallet { platform_address: &PlatformAddress, network: Network, ) -> Result { - // Find the derivation path by looking through watched_addresses - // and matching the PlatformAddress + // Convert to Core address for lookup + let core_address = platform_address.to_address_with_network(network); + + // Find the derivation path via PlatformWallet let derivation_path = self - .watched_addresses - .iter() - .filter(|(path, _)| path.is_platform_payment(network)) - .find_map(|(path, info)| { - // Try to convert the stored address to a PlatformAddress and compare - PlatformAddress::try_from(info.address.clone()) - .ok() - .filter(|addr| addr == platform_address) - .map(|_| path.clone()) - }) + .derivation_path_for_address(&core_address) .ok_or_else(|| { ProtocolError::Generic(format!( "Platform address {:?} not found in wallet", @@ -2280,98 +1180,15 @@ impl Wallet { )) })?; - // Get the seed bytes - let seed = *self.seed_bytes().map_err(ProtocolError::Generic)?; - - // Derive the private key - let extended_private_key = derivation_path - .derive_priv_ecdsa_for_master_seed(&seed, network) - .map_err(|e| ProtocolError::Generic(e.to_string()))?; - - Ok(extended_private_key.to_priv()) - } -} - -/// Signer implementation for Platform addresses -/// Allows the wallet to sign transactions that spend from Platform addresses -impl Signer for Wallet { - fn sign( - &self, - platform_address: &PlatformAddress, - data: &[u8], - ) -> Result { - // Only P2PKH addresses are supported for now - if !platform_address.is_p2pkh() { - return Err(ProtocolError::Generic( - "Only P2PKH Platform addresses are currently supported for signing".to_string(), - )); - } - - // The Signer trait doesn't pass network info, so we try each network. - // This is safe because: - // 1. A wallet instance only stores keys for ONE network (set at creation) - // 2. Platform addresses encode their network in the bech32m HRP (dash/tdash per DIP-18) - // 3. get_platform_address_private_key will only succeed for the correct network - // 4. Only one network's derivation will match the wallet's seed - let private_key = self - .get_platform_address_private_key(platform_address, Network::Mainnet) - .or_else(|_| self.get_platform_address_private_key(platform_address, Network::Testnet)) - .or_else(|_| self.get_platform_address_private_key(platform_address, Network::Devnet)) - .or_else(|_| { - self.get_platform_address_private_key(platform_address, Network::Regtest) - })?; - - // Sign the data - let signature = dash_sdk::dpp::dashcore::signer::sign(data, private_key.inner.as_ref()) - .map_err(|e| ProtocolError::Generic(format!("Failed to sign: {}", e)))?; - - Ok(BinaryData::new(signature.to_vec())) - } - - fn sign_create_witness( - &self, - platform_address: &PlatformAddress, - data: &[u8], - ) -> Result { - // Only P2PKH addresses are supported for now - if !platform_address.is_p2pkh() { - return Err(ProtocolError::Generic( - "Only P2PKH Platform addresses are currently supported for signing".to_string(), - )); - } - - // The Signer trait doesn't pass network info, so we try each network. - // This is safe - see comment in sign() above for explanation. - let private_key = self - .get_platform_address_private_key(platform_address, Network::Mainnet) - .or_else(|_| self.get_platform_address_private_key(platform_address, Network::Testnet)) - .or_else(|_| self.get_platform_address_private_key(platform_address, Network::Devnet)) - .or_else(|_| { - self.get_platform_address_private_key(platform_address, Network::Regtest) - })?; - - // Sign the data - produces a compact recoverable signature - // The public key will be recovered from the signature during verification - let signature = dash_sdk::dpp::dashcore::signer::sign(data, private_key.inner.as_ref()) - .map_err(|e| ProtocolError::Generic(format!("Failed to sign: {}", e)))?; - - Ok(AddressWitness::P2pkh { - signature: BinaryData::new(signature.to_vec()), - }) - } + // Get the seed bytes + let seed = *self.seed_bytes().map_err(ProtocolError::Generic)?; - fn can_sign_with(&self, platform_address: &PlatformAddress) -> bool { - // Only P2PKH addresses are supported - if !platform_address.is_p2pkh() { - return false; - } + // Derive the private key + let extended_private_key = derivation_path + .derive_priv_ecdsa_for_master_seed(&seed, network) + .map_err(|e| ProtocolError::Generic(e.to_string()))?; - // Check if we have the private key for this address - self.get_platform_address_private_key(platform_address, Network::Mainnet) - .or_else(|_| self.get_platform_address_private_key(platform_address, Network::Testnet)) - .or_else(|_| self.get_platform_address_private_key(platform_address, Network::Devnet)) - .or_else(|_| self.get_platform_address_private_key(platform_address, Network::Regtest)) - .is_ok() + Ok(extended_private_key.to_priv()) } } @@ -2509,68 +1326,23 @@ impl WalletAddressProvider { .insert(canonical_address, AddressFunds { nonce, balance }); } - /// Apply the sync results to a wallet, updating Platform address info. - /// - /// This updates the wallet's `platform_address_info` with the balances found during sync. - /// Also ensures addresses are registered in `known_addresses` and `watched_addresses` - /// so they appear in the UI. - /// Nonces are taken directly from the SDK sync results. - pub fn apply_results_to_wallet(&self, wallet: &mut Wallet) { - // Build a reverse lookup from address to index - let address_to_index: BTreeMap = self - .pending - .iter() - .map(|(idx, (_, addr))| (Wallet::canonical_address(addr, self.network), *idx)) - .collect(); - - for (address, funds) in &self.found_balances { - let canonical_address = Wallet::canonical_address(address, self.network); - - // Update wallet with synced balances - wallet.set_platform_address_info(canonical_address.clone(), funds.balance, funds.nonce); - - // Also register in known_addresses and watched_addresses if not already present - if !wallet.known_addresses.contains_key(&canonical_address) - && let Some(&index) = address_to_index.get(&canonical_address) - { - let derivation_path = DerivationPath::platform_payment_path( - self.network, - self.account, - self.key_class, - index, - ); - - wallet - .known_addresses - .insert(canonical_address.clone(), derivation_path.clone()); - - wallet.watched_addresses.insert( - derivation_path, - AddressInfo { - address: canonical_address.clone(), - path_type: DerivationPathType::CLEAR_FUNDS, - path_reference: DerivationPathReference::PlatformPayment, - }, - ); - } - } - } - - /// Populate stored balances and sync height from a wallet's known state. + /// Populate stored balances and sync height from DB-provided 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. + /// + /// `stored_info` contains `(Address, balance, nonce)` tuples loaded from the DB. pub fn with_stored_state( mut self, - wallet: &Wallet, + stored_info: &[(Address, u64, u32)], 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 { + // Populate stored_balances from DB-provided platform address info + for (core_addr, balance, nonce) in stored_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); @@ -2579,8 +1351,8 @@ impl WalletAddressProvider { *index, key.clone(), AddressFunds { - balance: info.balance, - nonce: info.nonce, + balance: *balance, + nonce: *nonce, }, )); break; @@ -2753,6 +1525,7 @@ mod tests { let seed_hash = ClosedKeyItem::compute_seed_hash(&seed); Wallet { + platform_wallet: None, wallet_seed: WalletSeed::Open(OpenWalletSeed { seed, wallet_info: ClosedKeyItem { @@ -2765,21 +1538,8 @@ mod tests { }), uses_password: false, master_bip44_ecdsa_extended_public_key, - address_balances: BTreeMap::new(), - address_total_received: BTreeMap::new(), - known_addresses: BTreeMap::new(), - watched_addresses: BTreeMap::new(), - unused_asset_locks: Vec::new(), alias: Some("Test Wallet".to_string()), - identities: HashMap::new(), - utxos: HashMap::new(), - transactions: Vec::new(), is_main: true, - confirmed_balance: 0, - unconfirmed_balance: 0, - total_balance: 0, - spv_balance_known: false, - platform_address_info: BTreeMap::new(), core_wallet_name: None, } } @@ -2803,380 +1563,21 @@ mod tests { OutPoint::new(Txid::from_slice(&txid_bytes).unwrap(), vout) } - /// Helper: create a test wallet pre-loaded with a single UTXO of the given value. - fn test_wallet_with_utxo(value: u64) -> Wallet { - let mut wallet = test_wallet(); - let addr = test_address(1); - add_utxo(&mut wallet, &addr, 1, 0, value); - wallet - } - - /// Helper: add a UTXO to a wallet - fn add_utxo(wallet: &mut Wallet, address: &Address, tx_index: u8, vout: u32, value: u64) { - let outpoint = test_outpoint(tx_index, vout); - let tx_out = TxOut { - value, - script_pubkey: address.script_pubkey(), - }; - wallet - .utxos - .entry(address.clone()) - .or_default() - .insert(outpoint, tx_out); - } - // ======================================================================== // Balance calculation tests // ======================================================================== #[test] - fn test_max_balance_empty_wallet() { + fn test_balance_returns_zero_without_platform_wallet() { let wallet = test_wallet(); - assert_eq!(wallet.max_balance(), 0); - } - - #[test] - fn test_max_balance_with_utxos() { - let mut wallet = test_wallet(); - let addr1 = test_address(1); - let addr2 = test_address(2); - - add_utxo(&mut wallet, &addr1, 1, 0, 50_000); - add_utxo(&mut wallet, &addr1, 2, 0, 30_000); - add_utxo(&mut wallet, &addr2, 3, 0, 20_000); - - assert_eq!(wallet.max_balance(), 100_000); - } - - #[test] - fn test_confirmed_balance_uses_spv_when_set() { - let mut wallet = test_wallet(); - let addr = test_address(1); - add_utxo(&mut wallet, &addr, 1, 0, 50_000); - - // With SPV balances set, confirmed_balance should return the SPV value - wallet.update_spv_balances(75_000, 5_000, 80_000); - assert_eq!(wallet.confirmed_balance_duffs(), 75_000); - } - - #[test] - fn test_confirmed_balance_falls_back_to_max_balance() { - let mut wallet = test_wallet(); - let addr = test_address(1); - add_utxo(&mut wallet, &addr, 1, 0, 50_000); - - // Without SPV balances, falls back to max_balance() - assert_eq!(wallet.confirmed_balance_duffs(), 50_000); - } - - #[test] - fn test_unconfirmed_balance() { - let mut wallet = test_wallet(); - wallet.update_spv_balances(100_000, 25_000, 125_000); - assert_eq!(wallet.unconfirmed_balance_duffs(), 25_000); - } - - #[test] - fn test_total_balance_uses_spv_when_set() { - let mut wallet = test_wallet(); - let addr = test_address(1); - add_utxo(&mut wallet, &addr, 1, 0, 50_000); - - wallet.update_spv_balances(75_000, 5_000, 80_000); - assert_eq!(wallet.total_balance_duffs(), 80_000); - } - - #[test] - fn test_total_balance_falls_back_to_max_balance() { - let mut wallet = test_wallet(); - let addr = test_address(1); - add_utxo(&mut wallet, &addr, 1, 0, 50_000); - - assert_eq!(wallet.total_balance_duffs(), 50_000); - } - - #[test] - fn test_has_balance() { - let mut wallet = test_wallet(); + // Without platform_wallet, all balance methods return 0 + assert_eq!(wallet.confirmed_balance_duffs(), 0); + assert_eq!(wallet.unconfirmed_balance_duffs(), 0); + assert_eq!(wallet.total_balance_duffs(), 0); assert!(!wallet.has_balance()); - - let addr = test_address(1); - add_utxo(&mut wallet, &addr, 1, 0, 50_000); - // has_balance checks confirmed_balance_duffs() > 0 || unconfirmed > 0 - // Without SPV, confirmed falls back to max_balance = 50_000 - assert!(wallet.has_balance()); - } - - #[test] - fn test_has_balance_with_only_unconfirmed() { - let mut wallet = test_wallet(); - wallet.update_spv_balances(0, 1000, 1000); - assert!(wallet.has_balance()); - } - - #[test] - fn test_update_spv_balances() { - let mut wallet = test_wallet(); - wallet.update_spv_balances(100, 50, 150); - assert_eq!(wallet.confirmed_balance, 100); - assert_eq!(wallet.unconfirmed_balance, 50); - 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 - // ======================================================================== - - #[test] - fn test_select_utxos_exact_amount() { - let wallet = test_wallet_with_utxo(100_000); - - let result = wallet.select_unspent_utxos_for(90_000, 10_000, false, None); - assert!(result.is_some()); - let (utxos, change) = result.unwrap(); - assert_eq!(utxos.len(), 1); - assert!(change.is_none()); // exact amount, no change - // Selection is non-mutating — wallet UTXOs unchanged - assert!(!wallet.utxos.is_empty()); - } - - #[test] - fn test_select_utxos_with_change() { - let wallet = test_wallet_with_utxo(200_000); - - let result = wallet.select_unspent_utxos_for(90_000, 10_000, false, None); - assert!(result.is_some()); - let (utxos, change) = result.unwrap(); - assert_eq!(utxos.len(), 1); - assert_eq!(change, Some(100_000)); // 200k - 90k - 10k = 100k change - } - - #[test] - fn test_select_utxos_insufficient_funds() { - let wallet = test_wallet_with_utxo(50_000); - - let result = wallet.select_unspent_utxos_for(90_000, 10_000, false, None); - assert!(result.is_none()); - } - - #[test] - fn test_select_utxos_multiple_utxos_needed() { - let mut wallet = test_wallet(); - let addr1 = test_address(1); - let addr2 = test_address(2); - add_utxo(&mut wallet, &addr1, 1, 0, 30_000); - add_utxo(&mut wallet, &addr2, 2, 0, 40_000); - add_utxo(&mut wallet, &addr1, 3, 0, 50_000); - - let result = wallet.select_unspent_utxos_for(100_000, 10_000, false, None); - assert!(result.is_some()); - let (utxos, change) = result.unwrap(); - let total_collected: u64 = utxos.values().map(|(tx_out, _)| tx_out.value).sum(); - assert!(total_collected >= 110_000); - if let Some(change_amount) = change { - assert_eq!(total_collected, 100_000 + 10_000 + change_amount); - } - } - - #[test] - fn test_select_utxos_allow_take_fee_from_amount() { - let wallet = test_wallet_with_utxo(100_000); - - // Request 100k amount + 10k fee = 110k total, but only 100k available - // With allow_take_fee_from_amount=true, should still succeed since total >= amount - let result = wallet.select_unspent_utxos_for(100_000, 10_000, true, None); - assert!(result.is_some()); - let (_utxos, change) = result.unwrap(); - assert!(change.is_none()); - } - - #[test] - fn test_select_utxos_allow_take_fee_but_not_enough_for_amount() { - let wallet = test_wallet_with_utxo(50_000); - - // Request 100k amount + 10k fee = 110k, only 50k available - // Even with take_fee_from_amount, 50k < 100k amount, so should fail - let result = wallet.select_unspent_utxos_for(100_000, 10_000, true, None); - assert!(result.is_none()); - } - - #[test] - fn test_select_utxos_zero_amount() { - let wallet = test_wallet_with_utxo(50_000); - - let result = wallet.select_unspent_utxos_for(0, 0, false, None); - assert!(result.is_some()); - let (utxos, change) = result.unwrap(); - assert!(utxos.is_empty()); - assert!(change.is_none()); - } - - /// 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) { - let seed_hash = wallet.seed_hash(); - let path = DerivationPath::from(vec![ - ChildNumber::Hardened { index: 44 }, - ChildNumber::Hardened { index: 1 }, - ChildNumber::Hardened { index: 0 }, - ChildNumber::Normal { index: 0 }, - ChildNumber::Normal { index: 0 }, - ]); - db.add_address_if_not_exists( - &seed_hash, - address, - &Network::Testnet, - &path, - DerivationPathReference::BIP44, - DerivationPathType::CLEAR_FUNDS, - Some(0), - ) - .expect("register test address"); - } - - #[test] - fn test_remove_utxos_removes_from_wallet() { - use crate::database::test_helpers::create_test_database; - - let mut wallet = test_wallet(); - let addr = test_address(1); - add_utxo(&mut wallet, &addr, 1, 0, 100_000); - add_utxo(&mut wallet, &addr, 2, 0, 200_000); - 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, None) - .unwrap(); - wallet - .remove_selected_utxos(&selected, &db, Network::Testnet) - .unwrap(); - - assert!(wallet.max_balance() < 300_000); - } - - #[test] - fn test_remove_utxos_cleans_empty_address_entries() { - use crate::database::test_helpers::create_test_database; - - let mut wallet = test_wallet(); - let addr = test_address(1); - 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, None) - .unwrap(); - wallet - .remove_selected_utxos(&selected, &db, Network::Testnet) - .unwrap(); - - assert!(!wallet.utxos.contains_key(&addr)); - } - - // ======================================================================== - // Platform address info tests - // ======================================================================== - - #[test] - fn test_total_platform_balance_empty() { - let wallet = test_wallet(); - assert_eq!(wallet.total_platform_balance(), 0); - } - - #[test] - fn test_total_platform_balance_with_entries() { - let mut wallet = test_wallet(); - let addr1 = test_address(1); - let addr2 = test_address(2); - - wallet.platform_address_info.insert( - addr1, - PlatformAddressInfo { - balance: 1_000_000, - nonce: 0, - }, - ); - wallet.platform_address_info.insert( - addr2, - PlatformAddressInfo { - balance: 2_000_000, - nonce: 1, - }, - ); - - assert_eq!(wallet.total_platform_balance(), 3_000_000); - } - - #[test] - fn test_set_platform_address_info_update() { - let mut wallet = test_wallet(); - let addr = test_address(1); - - wallet.set_platform_address_info(addr.clone(), 500_000, 3); - - 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); - } - - #[test] - fn test_get_platform_address_info_direct_lookup() { - let mut wallet = test_wallet(); - let addr = test_address(1); - - wallet.platform_address_info.insert( - addr.clone(), - PlatformAddressInfo { - balance: 100_000, - nonce: 1, - }, - ); - - let info = wallet.get_platform_address_info(&addr); - assert!(info.is_some()); - assert_eq!(info.unwrap().balance, 100_000); - } - - #[test] - fn test_get_platform_address_info_not_found() { - let wallet = test_wallet(); - let addr = test_address(1); - assert!(wallet.get_platform_address_info(&addr).is_none()); - } - // ======================================================================== // WalletTransaction tests // ======================================================================== @@ -3286,28 +1687,6 @@ mod tests { assert_eq!(wallet.seed_bytes().unwrap().len(), 64); } - #[test] - fn test_wallet_has_unused_asset_lock() { - let mut wallet = test_wallet(); - assert!(!wallet.has_unused_asset_lock()); - - // Add a dummy asset lock - wallet.unused_asset_locks.push(( - Transaction { - version: 2, - lock_time: 0, - input: vec![], - output: vec![], - special_transaction_payload: None, - }, - test_address(1), - 100_000, - None, - None, - )); - assert!(wallet.has_unused_asset_lock()); - } - // ======================================================================== // Derivation path helpers tests // ======================================================================== @@ -3541,90 +1920,21 @@ mod tests { } #[test] - fn test_receive_address_returns_first_unused() { - let mut wallet = test_wallet(); - // With no watched addresses, should derive address at index 0 - let addr = wallet - .receive_address(Network::Testnet, false, None) - .unwrap(); - assert!(!addr.to_string().is_empty()); - } - - /// Helper: manually register an address in watched_addresses so the wallet - /// considers it "known" (normally done by register_address with AppContext). - fn register_address_locally( - wallet: &mut Wallet, - address: &Address, - derivation_path: &DerivationPath, - ) { - wallet - .known_addresses - .insert(address.clone(), derivation_path.clone()); - wallet.watched_addresses.insert( - derivation_path.clone(), - AddressInfo { - address: address.clone(), - path_type: DerivationPathType::CLEAR_FUNDS, - path_reference: DerivationPathReference::BIP44, - }, - ); - } - - #[test] - fn test_receive_address_skip_known_with_no_funds() { + fn test_receive_address_returns_error_when_locked() { let mut wallet = test_wallet(); - - // Derive address at index 0 and register it locally - let addr0 = wallet - .derive_bip44_address(Network::Testnet, false, 0) - .unwrap(); - let path0 = DerivationPath::from(vec![ - ChildNumber::Hardened { index: 44 }, - ChildNumber::Hardened { index: 1 }, - ChildNumber::Hardened { index: 0 }, - ChildNumber::Normal { index: 0 }, - ChildNumber::Normal { index: 0 }, - ]); - register_address_locally(&mut wallet, &addr0, &path0); - - // With skip=false, should return the same known zero-balance address - let addr_same = wallet - .receive_address(Network::Testnet, false, None) - .unwrap(); - assert_eq!(addr0, addr_same); - - // With skip=true, should skip the known zero-balance address and get a new one - let addr_next = wallet - .receive_address(Network::Testnet, true, None) - .unwrap(); - assert_ne!(addr0, addr_next); + // Without PlatformWallet, receive_address should return Err + let result = wallet.receive_address(Network::Testnet, false, None); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), "Wallet is locked"); } #[test] - fn test_receive_address_skips_funded_addresses() { + fn test_change_address_returns_error_when_locked() { let mut wallet = test_wallet(); - - // Derive and register address at index 0 - let addr0 = wallet - .derive_bip44_address(Network::Testnet, false, 0) - .unwrap(); - let path0 = DerivationPath::from(vec![ - ChildNumber::Hardened { index: 44 }, - ChildNumber::Hardened { index: 1 }, - ChildNumber::Hardened { index: 0 }, - ChildNumber::Normal { index: 0 }, - ChildNumber::Normal { index: 0 }, - ]); - register_address_locally(&mut wallet, &addr0, &path0); - - // Fund it - wallet.address_balances.insert(addr0.clone(), 100_000); - - // With skip=false, should skip funded address and derive next index - let addr_next = wallet - .receive_address(Network::Testnet, false, None) - .unwrap(); - assert_ne!(addr0, addr_next, "Should skip funded address"); + // Without PlatformWallet, change_address should return Err + let result = wallet.change_address(Network::Testnet, None); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), "Wallet is locked"); } // ======================================================================== @@ -3655,44 +1965,6 @@ mod tests { assert_eq!(wallet.seed_hash(), original_hash); } - // ======================================================================== - // utxos_by_address tests - // ======================================================================== - - #[test] - fn test_utxos_by_address_empty() { - let wallet = test_wallet(); - assert!(wallet.utxos_by_address().is_empty()); - } - - #[test] - fn test_utxos_by_address_with_entries() { - let mut wallet = test_wallet(); - let addr1 = test_address(1); - let addr2 = test_address(2); - - add_utxo(&mut wallet, &addr1, 1, 0, 50_000); - add_utxo(&mut wallet, &addr1, 2, 0, 30_000); - add_utxo(&mut wallet, &addr2, 3, 0, 20_000); - - let utxos = wallet.utxos_by_address(); - assert_eq!(utxos.len(), 2); - - let addr1_balance: u64 = utxos - .iter() - .filter(|(a, _)| a == &addr1) - .map(|(_, b)| b) - .sum(); - assert_eq!(addr1_balance, 80_000); - - let addr2_balance: u64 = utxos - .iter() - .filter(|(a, _)| a == &addr2) - .map(|(_, b)| b) - .sum(); - assert_eq!(addr2_balance, 20_000); - } - // ======================================================================== // WalletArcRef tests // ======================================================================== diff --git a/src/model/wallet/utxos.rs b/src/model/wallet/utxos.rs index c000f25f5..cf93ef340 100644 --- a/src/model/wallet/utxos.rs +++ b/src/model/wallet/utxos.rs @@ -1,254 +1,25 @@ -use crate::context::AppContext; -use crate::database::{Database, WalletError}; -use crate::model::wallet::{DerivationPathHelpers, Wallet}; -use crate::spv::CoreBackendMode; -use dash_sdk::dashcore_rpc::RpcApi; -use dash_sdk::dashcore_rpc::json::ListUnspentResultEntry; +use crate::database::Database; +use crate::model::wallet::Wallet; use dash_sdk::dpp::dashcore::{Address, Network, OutPoint, TxOut}; -use std::collections::{BTreeMap, HashMap, HashSet}; +use std::collections::BTreeMap; impl Wallet { - /// Selects UTXOs sufficient to cover `amount + fee` without removing them from the wallet. - /// - /// Returns the selected UTXOs and an optional change amount, or `None` if there are - /// insufficient funds. The returned change value is computed from the caller-supplied - /// `fee` parameter; callers that recalculate fees afterward (e.g., based on the actual - /// number of inputs) should ignore the change value and recompute it. - /// - /// Use this when you need to inspect or validate the selection before - /// committing; call [`Self::remove_selected_utxos`] only once the operation - /// is guaranteed to succeed. - /// - /// **Important:** The caller must hold the wallet write lock (`&mut self` on `Wallet`) - /// continuously from this call through the corresponding [`Self::remove_selected_utxos`] - /// call. Dropping the lock between selection and removal would allow a concurrent - /// caller to select the same UTXOs, creating a double-spend. - #[allow(clippy::type_complexity)] - pub fn select_unspent_utxos_for( - &self, - amount: u64, - fee: u64, - allow_take_fee_from_amount: bool, - source_address: Option<&Address>, - ) -> Option<(BTreeMap, Option)> { - let target = amount.checked_add(fee)?; - let mut required: i64 = i64::try_from(target).ok()?; - let mut selected_utxos = BTreeMap::new(); - - let iter: Box)>> = - match source_address { - Some(addr) => Box::new(self.utxos.get(addr).into_iter().map(move |m| (addr, m))), - None => Box::new(self.utxos.iter()), - }; - for (address, outpoints) in iter { - for (outpoint, tx_out) in outpoints.iter() { - if required <= 0 { - break; - } - selected_utxos.insert(*outpoint, (tx_out.clone(), address.clone())); - required -= tx_out.value as i64; - } - } - - if required > 0 { - if allow_take_fee_from_amount { - let total_collected = target as i64 - required; - if total_collected >= amount as i64 { - let missing_fee = required; // required > 0 - let adjusted_amount = amount as i64 - missing_fee; - if adjusted_amount <= 0 { - return None; - } - Some((selected_utxos, None)) - } else { - None - } - } else { - None - } - } else { - let total_input = target as i64 - required; - let change = total_input as u64 - amount - fee; - let change_option = if change > 0 { Some(change) } else { None }; - Some((selected_utxos, change_option)) - } - } - - /// Removes the given UTXOs from the wallet's in-memory UTXO set, persists - /// the removal to the database, and recalculates affected address balances. - /// - /// Typically called with the result of [`Self::select_unspent_utxos_for`] - /// after the transaction has been fully built and signed. + /// Persist UTXO removals to the database and recalculate affected + /// address balances. In-memory UTXOs are tracked by ManagedWalletInfo. pub fn remove_selected_utxos( - &mut self, + &self, selected: &BTreeMap, db: &Database, network: Network, ) -> Result<(), String> { - // Update in-memory UTXO map - for (outpoint, (_, address)) in selected { - if let Some(outpoints) = self.utxos.get_mut(address) { - outpoints.remove(outpoint); - if outpoints.is_empty() { - self.utxos.remove(address); - } - } else { - tracing::debug!( - ?outpoint, - %address, - "remove_selected_utxos: outpoint not found in wallet, skipping" - ); - } - } - - // Persist UTXO removals to the database let network_str = network.to_string(); for outpoint in selected.keys() { db.drop_utxo(outpoint, &network_str) .map_err(|e| e.to_string())?; } - // Recalculate and persist balances for affected addresses self.recalculate_affected_address_balances_with_db(selected, db)?; Ok(()) } - - /// Reload UTXOs from Core RPC, updating both in-memory state and database. - /// - /// Returns `true` if the UTXO set changed (something was added or - /// removed), `false` if nothing changed. In SPV mode this is a no-op - /// (`Ok(false)`) because the wallet state is authoritative — UTXOs are - /// synced continuously via compact block filters. - pub fn reload_utxos(&mut self, app_context: &AppContext) -> Result { - let network = app_context.network; - - // SPV wallet state is authoritative — reload is a no-op. - if app_context.core_backend_mode() == CoreBackendMode::Spv { - return Ok(false); - } - - let core_client = app_context - .core_client_for_wallet(self.core_wallet_name.as_deref()) - .map_err(|e| e.to_string())?; - - // Collect Core chain addresses for which we want to load UTXOs. - // Platform addresses are NOT valid on Core chain and must be excluded. - let addresses: Vec<_> = self - .known_addresses - .iter() - .filter(|(_, path)| !path.is_platform_payment(network)) - .map(|(addr, _)| addr) - .collect(); - if tracing::enabled!(tracing::Level::TRACE) { - for addr in addresses.iter() { - let (net, payload) = (*addr).clone().into_parts(); - tracing::trace!(net=net.to_string(),payload=?payload , "Address to load UTXOs for"); - } - } - - // Calling list_unspent with an empty addresses vector will return all UTXOs, - // which is not what we want here. Instead, we handle the empty case explicitly. - let utxos: Vec = if addresses.is_empty() { - Vec::new() - } else { - core_client - .list_unspent(None, None, Some(&addresses), Some(false), None) - .map_err(|e| e.to_string())? - }; - - // Initialize the HashMap to store the new UTXOs. - let mut new_utxo_map = HashMap::new(); - // Build a set of new OutPoints for easy comparison. - let mut new_outpoints = HashSet::new(); - - // Iterate over the retrieved UTXOs and populate the HashMaps. - for utxo in utxos { - let outpoint = OutPoint::new(utxo.txid, utxo.vout); - let tx_out = TxOut { - value: utxo.amount.to_sat(), - script_pubkey: utxo.script_pub_key.clone(), - }; - new_utxo_map.insert(outpoint, tx_out); - new_outpoints.insert(outpoint); - } - - // Collect current UTXOs into a set for comparison - let mut old_outpoints = HashSet::new(); - for (_address, utxos) in self.utxos.iter() { - for (outpoint, _tx_out) in utxos.iter() { - old_outpoints.insert(*outpoint); - } - } - - // Determine UTXOs to be removed and added - let removed_outpoints: HashSet<_> = - old_outpoints.difference(&new_outpoints).cloned().collect(); - let added_outpoints: HashSet<_> = - new_outpoints.difference(&old_outpoints).cloned().collect(); - - let changed = !removed_outpoints.is_empty() || !added_outpoints.is_empty(); - - // Now update self.utxos by removing UTXOs not present in new_outpoints - let current_utxos = &mut self.utxos; - // Remove UTXOs that are no longer unspent - for utxos in current_utxos.values_mut() { - utxos.retain(|outpoint, _| new_outpoints.contains(outpoint)); - } - // Remove addresses with no UTXOs - current_utxos.retain(|_, utxos| !utxos.is_empty()); - - // Add new UTXOs to self.utxos - let current_utxos = &mut self.utxos; - for (outpoint, tx_out) in &new_utxo_map { - // Get the address from the script_pubkey - let address = Address::from_script(&tx_out.script_pubkey, network) - .map_err(|e| WalletError::AddressError(e).to_string())?; - // Add or update the UTXO in the wallet - current_utxos - .entry(address.clone()) - .or_default() - .insert(*outpoint, tx_out.clone()); - } - - // Persist changes to the database only when something actually changed - if changed { - let db = &app_context.db; - - for outpoint in removed_outpoints { - db.drop_utxo(&outpoint, &network.to_string()) - .map_err(|e| e.to_string())?; - } - - for outpoint in added_outpoints { - let tx_out = &new_utxo_map[&outpoint]; - let address = Address::from_script(&tx_out.script_pubkey, network) - .map_err(|e| WalletError::AddressError(e).to_string())?; - - db.insert_utxo( - outpoint.txid.as_ref(), - outpoint.vout, - &address, - tx_out.value, - tx_out.script_pubkey.as_bytes(), - network, - ) - .map_err(|e| e.to_string())?; - } - } - - Ok(changed) - } - - /// Get all addresses with their total UTXO balances - pub fn utxos_by_address(&self) -> Vec<(Address, u64)> { - self.utxos - .iter() - .map(|(address, utxos)| { - let total_balance: u64 = utxos.values().map(|tx_out| tx_out.value).sum(); - (address.clone(), total_balance) - }) - .filter(|(_, balance)| *balance > 0) - .collect() - } } diff --git a/src/platform_wallet_bridge.rs b/src/platform_wallet_bridge.rs new file mode 100644 index 000000000..a8fc1d631 --- /dev/null +++ b/src/platform_wallet_bridge.rs @@ -0,0 +1,164 @@ +//! Bridge module for platform-wallet integration. +//! +//! Re-exports types from `platform-wallet` and provides conversion helpers +//! between old wallet types and new platform wallet types. +//! +//! # Migration plan for AppContext +//! +//! The current `AppContext` stores wallets as: +//! ```ignore +//! wallets: RwLock>>> +//! ``` +//! where `WalletSeedHash = [u8; 32]` is SHA-256 of the wallet seed. +//! +//! In the new platform-wallet model, the equivalent is: +//! - `WalletId = [u8; 32]` (SHA-256 of the root public key, from `key-wallet`) +//! - `PlatformWalletManager` manages multiple `PlatformWallet` instances +//! internally via `RwLock>` +//! +//! Key differences: +//! - `WalletSeedHash` is derived from the seed; `WalletId` is derived from +//! the root public key. Both are `[u8; 32]` and serve as unique wallet identifiers. +//! They will produce different values for the same wallet. +//! - The old model uses `Arc>` for interior mutability; +//! the new model uses `PlatformWallet` which is cheaply cloneable (all Arc fields). +//! - `PlatformWallet` subsumes the old `Wallet` by composing `CoreWallet`, +//! `IdentityWallet`, `DashPayWallet`, and `PlatformAddressWallet`. + +// ── Primary types ────────────────────────────────────────────────────── + +pub use platform_wallet::IdentityManager; +pub use platform_wallet::ManagedIdentity; +pub use platform_wallet::PlatformWallet; +pub use platform_wallet::PlatformWalletError; +pub use platform_wallet::PlatformWalletEvent; +pub use platform_wallet::PlatformWalletManager; + +// ── Sub-wallet types ─────────────────────────────────────────────────── + +pub use platform_wallet::CoreWallet; +pub use platform_wallet::TokenWallet; +pub use platform_wallet::WalletBalance; +pub use platform_wallet::wallet::WalletId; + +// ── Identity sub-types ──────────────────────────────────────────────── + +pub use platform_wallet::DpnsNameInfo as ManagedDpnsNameInfo; +pub use platform_wallet::IdentityFunding; +pub use platform_wallet::IdentityStatus as ManagedIdentityStatus; +pub use platform_wallet::KeyStorage as ManagedKeyStorage; +pub use platform_wallet::PrivateKeyData as ManagedPrivateKeyData; + +// ── Supporting types ─────────────────────────────────────────────────── + +pub use platform_wallet::BlockTime; +pub use platform_wallet::ContactRequest; +pub use platform_wallet::EstablishedContact; + +// ── Address info (moved from platform-wallet — UI-only type) ───────── + +use dash_sdk::dpp::dashcore::Address; +use dash_sdk::dpp::key_wallet::bip32::DerivationPath; +use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + +/// Per-address info for UI consumption. +#[derive(Debug, Clone, PartialEq)] +pub struct CoreAddressInfo { + /// The address itself. + pub address: Address, + /// Full HD derivation path for this address. + pub derivation_path: DerivationPath, + /// Current balance held at this address (in satoshis). + pub balance: u64, + /// Total amount ever received by this address (in satoshis). + pub total_received: u64, + /// Number of UTXOs currently held at this address. + pub utxo_count: usize, + /// Whether this address has ever been used in a transaction. + pub is_used: bool, + /// Index within its address pool. + pub index: u32, + /// Account index this address belongs to, if applicable. + pub account_index: Option, +} + +impl CoreAddressInfo { + /// Build a `CoreAddressInfo` list for every address across all accounts. + pub fn all_from_wallet_info(info: &ManagedWalletInfo) -> Vec { + let mut result = Vec::new(); + + for account in info.accounts.all_accounts() { + let account_index = account.index(); + + let mut utxo_counts: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for utxo in account.utxos.values() { + *utxo_counts.entry(utxo.address.clone()).or_default() += 1; + } + + for pool in account.account_type.address_pools() { + for addr_info in pool.addresses.values() { + result.push(CoreAddressInfo { + address: addr_info.address.clone(), + derivation_path: addr_info.path.clone(), + balance: addr_info.balance, + total_received: addr_info.total_received, + utxo_count: utxo_counts.get(&addr_info.address).copied().unwrap_or(0), + is_used: addr_info.used, + index: addr_info.index, + account_index, + }); + } + } + } + + result + } +} + +// ── Mapping helpers ──────────────────────────────────────────────────── + +use crate::model::wallet::WalletSeedHash; +use std::collections::BTreeMap; + +/// A bidirectional mapping between `WalletSeedHash` (old key, SHA256 of seed) +/// and `WalletId` (new key, SHA256 of root public key). +/// +/// During migration, both the old `wallets` map (keyed by seed hash) and the +/// new `PlatformWalletManager` (keyed by wallet ID) coexist. This mapping +/// allows code that has one key to look up the other. +#[derive(Debug, Default, Clone)] +pub struct WalletIdMapping { + seed_to_wallet: BTreeMap, + wallet_to_seed: BTreeMap, +} + +impl WalletIdMapping { + pub fn new() -> Self { + Self::default() + } + + /// Register a bidirectional mapping between seed hash and wallet ID. + pub fn insert(&mut self, seed_hash: WalletSeedHash, wallet_id: WalletId) { + self.seed_to_wallet.insert(seed_hash, wallet_id); + self.wallet_to_seed.insert(wallet_id, seed_hash); + } + + /// Remove a mapping by seed hash. + pub fn remove_by_seed_hash(&mut self, seed_hash: &WalletSeedHash) { + if let Some(wallet_id) = self.seed_to_wallet.remove(seed_hash) { + self.wallet_to_seed.remove(&wallet_id); + } + } + + /// Look up the `WalletId` for a given `WalletSeedHash`. + pub fn wallet_id_for_seed(&self, seed_hash: &WalletSeedHash) -> Option<&WalletId> { + self.seed_to_wallet.get(seed_hash) + } + + /// Look up the `WalletSeedHash` for a given `WalletId`. + #[allow(dead_code)] + pub fn seed_hash_for_wallet(&self, wallet_id: &WalletId) -> Option<&WalletSeedHash> { + self.wallet_to_seed.get(wallet_id) + } +} diff --git a/src/spv/event_bridge.rs b/src/spv/event_bridge.rs new file mode 100644 index 000000000..15af47d58 --- /dev/null +++ b/src/spv/event_bridge.rs @@ -0,0 +1,293 @@ +//! Subscribes to [`PlatformWalletEvent`] and translates SPV events into +//! evo-tool's app-level concerns: [`ConnectionStatus`] updates, sync progress +//! snapshots, and reconcile signals. +//! +//! Replaces the old `SpvEventHandler` that was tightly coupled to the +//! dash-spv `EventHandler` trait. + +use crate::context::connection_status::ConnectionStatus; +use crate::spv::types::failed_manager_name; +use crate::spv::types::{SpvStatus, SpvStatusSnapshot}; +use dash_sdk::dash_spv::network::NetworkEvent; +use dash_sdk::dash_spv::sync::{SyncEvent, SyncProgress as SpvSyncProgress, SyncState}; +use platform_wallet::events::{PlatformWalletEvent, SpvEvent, WalletEvent}; +use std::sync::{Arc, Mutex, RwLock}; +use std::time::SystemTime; +use tokio::sync::{broadcast, mpsc}; + +/// Bridges [`PlatformWalletEvent`] into evo-tool's UI-facing state. +/// +/// Owns the shared [`SpvStatusSnapshot`] and pushes updates to +/// [`ConnectionStatus`] and the reconcile channel exactly as the old +/// `SpvEventHandler` did, but without requiring the dash-spv +/// `EventHandler` trait. +pub struct SpvEventBridge { + connection_status: Arc, + status: Arc>, + reconcile_tx: Mutex>, +} + +impl SpvEventBridge { + /// Create a new event bridge. + /// + /// * `connection_status` — shared status indicator updated on every + /// relevant event. + /// * `reconcile_tx` — channel used to signal wallet reconciliation + /// (debounced downstream). + pub fn new(connection_status: Arc, reconcile_tx: mpsc::Sender<()>) -> Self { + Self { + connection_status, + status: Arc::new(RwLock::new(SpvStatusSnapshot::default())), + reconcile_tx: Mutex::new(reconcile_tx), + } + } + + /// Replace the reconcile channel with a fresh one. + /// + /// Returns the new receiver. Called by `start_spv()` so each SPV + /// session gets a clean reconcile pipeline. + pub fn new_reconcile_channel(&self) -> mpsc::Receiver<()> { + let (tx, rx) = mpsc::channel(64); + if let Ok(mut guard) = self.reconcile_tx.lock() { + *guard = tx; + } + rx + } + + /// Read the current status snapshot (used by the tooltip and UI). + pub fn status(&self) -> SpvStatusSnapshot { + self.status.read().map(|g| g.clone()).unwrap_or_default() + } + + /// Dispatch a single [`PlatformWalletEvent`] to the appropriate handler. + pub fn handle_event(&self, event: &PlatformWalletEvent) { + match event { + PlatformWalletEvent::Spv(spv) => match spv { + SpvEvent::Progress(progress) => self.handle_progress(progress), + SpvEvent::Sync(sync_event) => self.handle_sync_event(sync_event), + SpvEvent::Network(net_event) => self.handle_network_event(net_event), + }, + PlatformWalletEvent::Wallet(wallet_event) => { + self.handle_wallet_event(wallet_event); + } + } + } + + /// Run the event loop, consuming events from the broadcast channel + /// until the channel closes. + pub async fn run(self: Arc, mut rx: broadcast::Receiver) { + loop { + match rx.recv().await { + Ok(event) => self.handle_event(&event), + Err(broadcast::error::RecvError::Lagged(n)) => { + tracing::warn!("SpvEventBridge lagged, skipped {n} events"); + } + Err(broadcast::error::RecvError::Closed) => break, + } + } + } + + // ------------------------------------------------------------------ + // Internal handlers — logic extracted from the old SpvEventHandler + // ------------------------------------------------------------------ + + /// Handle wallet-level events (balance changes, transaction status, etc.). + /// + /// `BalanceUpdated` events are emitted by `WalletManager` after SPV block + /// processing. The `WalletBalance` atomics are already updated during block + /// processing via `PlatformWalletInfo::check_core_transaction()`, so the + /// main purpose here is to signal reconciliation so evo-tool's UI-facing + /// state picks up the change. + fn handle_wallet_event(&self, event: &WalletEvent) { + match event { + WalletEvent::BalanceUpdated { + wallet_id, + spendable, + unconfirmed, + .. + } => { + tracing::debug!( + wallet_id = %hex::encode(wallet_id), + spendable, + unconfirmed, + "BalanceUpdated event received" + ); + // Signal reconciliation so the UI picks up the new balance. + if let Ok(tx) = self.reconcile_tx.lock() { + let _ = tx.try_send(()); + } + } + WalletEvent::TransactionReceived { wallet_id, record, .. } => { + tracing::debug!( + wallet_id = %hex::encode(wallet_id), + txid = %record.txid, + "TransactionReceived event" + ); + // Signal reconciliation for new transactions. + if let Ok(tx) = self.reconcile_tx.lock() { + let _ = tx.try_send(()); + } + } + WalletEvent::TransactionStatusChanged { wallet_id, txid, status, .. } => { + tracing::debug!( + wallet_id = %hex::encode(wallet_id), + %txid, + %status, + "TransactionStatusChanged event" + ); + // Signal reconciliation for status changes. + if let Ok(tx) = self.reconcile_tx.lock() { + let _ = tx.try_send(()); + } + } + } + } + + /// Translate a sync progress update into status + connection status changes. + /// + /// Mirrors the old `EventHandler::on_progress` implementation. + fn handle_progress(&self, progress: &SpvSyncProgress) { + let is_synced = progress.is_synced(); + let is_error = progress.state() == SyncState::Error; + + // Update the shared snapshot (read by the UI tooltip / progress bars). + let (new_status, error_msg) = if let Ok(mut snap) = self.status.write() { + snap.sync_progress = Some(progress.clone()); + snap.last_updated = Some(SystemTime::now()); + + // Derive SpvStatus from progress. + let new_status = if is_synced { + snap.status = SpvStatus::Running; + Some(SpvStatus::Running) + } else if is_error { + snap.status = SpvStatus::Error; + Some(SpvStatus::Error) + } else if !matches!( + snap.status, + SpvStatus::Stopping | SpvStatus::Stopped | SpvStatus::Error + ) { + snap.status = SpvStatus::Syncing; + Some(SpvStatus::Syncing) + } else { + None + }; + + // Record error message (only the first one). + let error_msg = if is_error && snap.last_error.is_none() { + let phase = failed_manager_name(progress); + let msg = format!("Sync failed: {phase} (reported by SPV progress channel)"); + snap.last_error = Some(msg.clone()); + Some(msg) + } else { + None + }; + + (new_status, error_msg) + } else { + (None, None) + }; + + // Push to ConnectionStatus for the status indicator. + let cs = &self.connection_status; + if let Some(s) = new_status { + cs.set_spv_status(s); + } + if let Some(msg) = error_msg { + cs.set_spv_last_error(Some(msg)); + } else if !is_error { + // Only clear errors when the snapshot status is not Error — + // otherwise on_sync_event(ManagerError) sets the error but the + // next progress update would immediately clear it. + let snapshot_is_error = self + .status + .read() + .ok() + .is_some_and(|s| s.status == SpvStatus::Error); + if !snapshot_is_error { + cs.set_spv_last_error(None); + } + } + cs.refresh_state(); + } + + /// Handle sync lifecycle events (SyncComplete, ManagerError, + /// BlockProcessed, ChainLock, InstantLock). + /// + /// Mirrors the old `EventHandler::on_sync_event` implementation + /// (minus the dead finality channel code). + fn handle_sync_event(&self, event: &SyncEvent) { + // Transition to Running on SyncComplete. + if matches!(event, SyncEvent::SyncComplete { .. }) { + if let Ok(mut snap) = self.status.write() { + snap.status = SpvStatus::Running; + } + self.connection_status.set_spv_status(SpvStatus::Running); + self.connection_status.refresh_state(); + } + + // Handle ManagerError — only fatal for core managers (headers, filters). + // Masternode sync errors are non-fatal: the wallet is fully functional + // for transactions without masternodes (testnet often has QRInfo errors). + if let SyncEvent::ManagerError { manager, error } = event { + let is_masternode = manager.to_string() == "Masternode"; + if is_masternode { + tracing::warn!("SPV masternode sync error (non-fatal): {error}"); + } else { + tracing::error!("SPV manager {manager} reported error: {error}"); + if let Ok(mut snap) = self.status.write() { + snap.status = SpvStatus::Error; + if snap.last_error.is_none() { + let limit = error.floor_char_boundary(100); + let msg = format!("Sync manager {manager} failed: {}", &error[..limit]); + snap.last_error = Some(msg.clone()); + self.connection_status.set_spv_last_error(Some(msg)); + } + } + self.connection_status.set_spv_status(SpvStatus::Error); + self.connection_status.refresh_state(); + } + } + + // Signal reconciliation for wallet-relevant events. + let should_signal = matches!( + event, + SyncEvent::BlockProcessed { .. } + | SyncEvent::ChainLockReceived { .. } + | SyncEvent::InstantLockReceived { .. } + | SyncEvent::SyncComplete { .. } + ); + + if should_signal { + // Silently discard full-channel errors — reconcile is debounced + // downstream. + if let Ok(tx) = self.reconcile_tx.lock() { + let _ = tx.try_send(()); + } + } + } + + /// Handle network events (peer count changes). + /// + /// Mirrors the old `EventHandler::on_network_event` implementation. + fn handle_network_event(&self, event: &NetworkEvent) { + if let NetworkEvent::PeersUpdated { + connected_count, .. + } = event + { + if let Ok(mut snap) = self.status.write() { + snap.connected_peers = *connected_count; + } + self.connection_status + .set_spv_connected_peers((*connected_count).min(u16::MAX as usize) as u16); + self.connection_status.refresh_state(); + } + } +} + +impl std::fmt::Debug for SpvEventBridge { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SpvEventBridge") + .field("status", &self.status()) + .finish() + } +} diff --git a/src/spv/manager.rs b/src/spv/manager.rs deleted file mode 100644 index d68aed31e..000000000 --- a/src/spv/manager.rs +++ /dev/null @@ -1,1482 +0,0 @@ -use super::error::{SpvError, SpvResult}; -use crate::config::NetworkConfig; -use crate::context::connection_status::ConnectionStatus; -use crate::model::wallet::WalletSeedHash; -use crate::utils::tasks::TaskManager; -use dash_sdk::dash_spv::client::config::MempoolStrategy; -use dash_sdk::dash_spv::client::event_handler::EventHandler; -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}; -use dash_sdk::dpp::key_wallet::bip32::{DerivationPath, ExtendedPrivKey}; -use dash_sdk::dpp::key_wallet::wallet::initialization::WalletAccountCreationOptions; -use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::{ - ManagedWalletInfo, transaction_building::AccountTypePreference, - wallet_info_interface::WalletInfoInterface, -}; -use dash_sdk::dpp::key_wallet_manager::{ - WalletError, WalletEvent, WalletId, WalletInterface, WalletManager, -}; -use std::fmt; -use std::fs; -use std::net::ToSocketAddrs; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex, RwLock}; -use std::time::SystemTime; -use tokio::sync::RwLock as AsyncRwLock; -use tokio::sync::mpsc; -use tokio_util::sync::CancellationToken; -use zeroize::Zeroize; - -/// Preferred backend for Core-level operations. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum CoreBackendMode { - #[default] - Rpc = 0, - Spv = 1, -} - -impl CoreBackendMode { - pub fn as_u8(self) -> u8 { - self as u8 - } -} - -impl From for CoreBackendMode { - fn from(value: u8) -> Self { - match value { - 1 => CoreBackendMode::Spv, - _ => CoreBackendMode::Rpc, - } - } -} - -/// High-level status of the SPV client runtime. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -#[repr(u8)] -pub enum SpvStatus { - #[default] - Idle = 0, - Starting = 1, - Syncing = 2, - Running = 3, - Stopping = 4, - Stopped = 5, - Error = 6, -} - -impl SpvStatus { - pub fn is_active(self) -> bool { - matches!( - self, - SpvStatus::Starting | SpvStatus::Syncing | SpvStatus::Running | SpvStatus::Stopping - ) - } -} - -impl std::fmt::Display for SpvStatus { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - SpvStatus::Idle => write!(f, "Idle"), - SpvStatus::Starting => write!(f, "Starting"), - SpvStatus::Syncing => write!(f, "Syncing"), - SpvStatus::Running => write!(f, "Running"), - SpvStatus::Stopping => write!(f, "Stopping"), - SpvStatus::Stopped => write!(f, "Stopped"), - SpvStatus::Error => write!(f, "Error"), - } - } -} - -impl From for SpvStatus { - fn from(value: u8) -> Self { - match value { - 0 => SpvStatus::Idle, - 1 => SpvStatus::Starting, - 2 => SpvStatus::Syncing, - 3 => SpvStatus::Running, - 4 => SpvStatus::Stopping, - 5 => SpvStatus::Stopped, - 6 => SpvStatus::Error, - _ => SpvStatus::Idle, - } - } -} - -/// Snapshot of the SPV runtime state for UI consumption. -/// Uses dash-spv's built-in progress types directly instead of duplicating. -#[derive(Debug, Clone, Default)] -pub struct SpvStatusSnapshot { - pub status: SpvStatus, - pub sync_progress: Option, - pub last_error: Option, - pub started_at: Option, - pub last_updated: Option, - pub connected_peers: usize, -} - -/// Type alias for the SPV client with our specific configuration -type SpvClient = DashSpvClient< - WalletManager, - PeerNetworkManager, - DiskStorageManager, - SpvEventHandler, ->; - -/// EventHandler implementation that bridges dash-spv push events into -/// SpvManager's shared state (progress, status, peer count, errors). -/// -/// Constructed during `build_client()` and owned by the SPV client. -/// All fields are `Arc`-wrapped so they share state with SpvManager. -pub(crate) struct SpvEventHandler { - sync_progress_state: Arc>>, - progress_updated_at: Arc>>, - status: Arc>, - last_error: Arc>>, - connected_peers: Arc>, - connection_status: Option>, - reconcile_tx: Arc>>>, - finality_tx: Arc>>>, -} - -impl EventHandler for SpvEventHandler { - fn on_progress(&self, progress: &SpvSyncProgress) { - let is_synced = progress.is_synced(); - let is_error = progress.state() == SyncState::Error; - - // Update sync progress state (read by UI tooltip and progress bars). - if let Ok(mut stored) = self.sync_progress_state.write() { - *stored = Some(progress.clone()); - } - if let Ok(mut updated_at) = self.progress_updated_at.write() { - *updated_at = Some(SystemTime::now()); - } - - // Update SpvStatus based on progress. - let new_status = if let Ok(mut status_guard) = self.status.write() { - if is_synced { - *status_guard = SpvStatus::Running; - Some(SpvStatus::Running) - } else if is_error { - *status_guard = SpvStatus::Error; - Some(SpvStatus::Error) - } else if !matches!( - *status_guard, - SpvStatus::Stopping | SpvStatus::Stopped | SpvStatus::Error - ) { - *status_guard = SpvStatus::Syncing; - Some(SpvStatus::Syncing) - } else { - None - } - } else { - None - }; - - // Write last_error outside status lock (consistent lock ordering). - let mut error_msg = None; - if is_error - && let Ok(mut err_guard) = self.last_error.write() - && err_guard.is_none() - { - let phase = SpvManager::failed_manager_name(progress); - let msg = format!("Sync failed: {phase} (reported by SPV progress channel)"); - *err_guard = Some(msg.clone()); - error_msg = Some(msg); - } - - // Push to ConnectionStatus for the status indicator. - if let Some(cs) = &self.connection_status { - if let Some(s) = new_status { - cs.set_spv_status(s); - } - if let Some(msg) = error_msg { - cs.set_spv_last_error(Some(msg)); - } else if !is_error - && !matches!(self.status.read().ok().as_deref(), Some(&SpvStatus::Error)) - { - // Only clear errors when status is not Error — otherwise - // on_sync_event(ManagerError) sets the error but the next - // on_progress call would immediately clear it. - cs.set_spv_last_error(None); - } - cs.refresh_state(); - } - } - - fn on_sync_event(&self, event: &SyncEvent) { - // Transition to Running on SyncComplete. - if matches!(event, SyncEvent::SyncComplete { .. }) { - if let Ok(mut guard) = self.status.write() { - *guard = SpvStatus::Running; - } - if let Some(cs) = &self.connection_status { - cs.set_spv_status(SpvStatus::Running); - cs.refresh_state(); - } - } - - // Transition to Error on ManagerError. - if let SyncEvent::ManagerError { manager, error } = event { - tracing::error!("SPV manager {manager} reported error: {error}"); - if let Ok(mut guard) = self.status.write() { - *guard = SpvStatus::Error; - } - let limit = error.floor_char_boundary(100); - let msg = format!("Sync manager {manager} failed: {}", &error[..limit]); - if let Ok(mut err_guard) = self.last_error.write() { - if err_guard.is_none() { - *err_guard = Some(msg.clone()); - } else { - tracing::warn!( - %manager, error, "SPV last_error already set, ignoring: {msg}" - ); - } - } - if let Some(cs) = &self.connection_status { - cs.set_spv_status(SpvStatus::Error); - cs.set_spv_last_error(Some(msg)); - cs.refresh_state(); - } - } - - // Signal reconciliation for wallet-relevant events. - let should_signal = matches!( - event, - SyncEvent::BlockProcessed { .. } - | SyncEvent::ChainLockReceived { .. } - | SyncEvent::InstantLockReceived { .. } - | SyncEvent::SyncComplete { .. } - ); - - // Forward finality-relevant events for asset lock proof construction. - let finality_tx = self.finality_tx.lock().ok().and_then(|g| g.clone()); - if let Some(ref ftx) = finality_tx { - match event { - SyncEvent::InstantLockReceived { instant_lock, .. } => { - if let Err(e) = ftx.try_send(AssetLockFinalityEvent::InstantLock { - txid: instant_lock.txid, - instant_lock: Box::new(instant_lock.clone()), - }) { - tracing::warn!( - "Failed to forward InstantLock for txid {}: {e}", - instant_lock.txid - ); - } - } - SyncEvent::ChainLockReceived { chain_lock, .. } => { - if let Err(e) = ftx.try_send(AssetLockFinalityEvent::ChainLock { - height: chain_lock.block_height, - }) { - tracing::warn!( - "Failed to forward ChainLock for height {}: {e}", - chain_lock.block_height - ); - } - } - _ => {} - } - } - - if should_signal && let Some(tx) = self.reconcile_tx.lock().ok().and_then(|g| g.clone()) { - // Silently discard full-channel errors — reconcile is debounced downstream. - let _ = tx.try_send(()); - } - } - - fn on_network_event(&self, event: &NetworkEvent) { - if let NetworkEvent::PeersUpdated { - connected_count, .. - } = event - { - if let Ok(mut guard) = self.connected_peers.write() { - *guard = *connected_count; - } - if let Some(cs) = &self.connection_status { - cs.set_spv_connected_peers((*connected_count).min(u16::MAX as usize) as u16); - cs.refresh_state(); - } - } - } -} - -/// Events forwarded from SPV to AppContext for asset lock proof construction. -pub(crate) enum AssetLockFinalityEvent { - InstantLock { - txid: Txid, - instant_lock: Box, - }, - ChainLock { - height: u32, - }, -} - -/// Manages SPV client lifecycle and exposes status updates. -/// Uses dash-spv's built-in state management, running as a spawned task on the main tokio runtime. -/// -/// The client itself is owned by the background task and accessed through -/// its internally-shared components (wallet, storage, etc.) rather than through additional locking. -pub struct SpvManager { - network: Network, - data_dir: PathBuf, - config: Arc>, - subtasks: Arc, - wallet: Arc>>, - // Storage manager for direct access to SPV data (shared component from client) - storage: Arc>>>>, - // Clone of the running SPV client for direct queries (quorum lookups, etc.) - spv_client: Arc>>, - status: Arc>, - last_error: Arc>>, - started_at: Arc>>, - sync_progress_state: Arc>>, - progress_updated_at: Arc>>, - // mapping DET wallet seed_hash -> SPV wallet identifier (if created) - det_wallets: Arc>>, - // signal channel to trigger external reconcile on wallet-related events - reconcile_tx: Arc>>>, - // signal channel to forward instant lock / chain lock events for asset lock proof construction - finality_tx: Arc>>>, - // Whether to use local Dash Core node instead of DNS seed discovery - use_local_node: Arc, - // Cancellation token for clean shutdown - stop_token: Mutex>, - // Channel to send requests to the SPV runtime thread - request_tx: Mutex>>, - // Network manager clone for broadcasting transactions (set when client is running) - network_manager: Arc>>, - // Number of currently connected SPV peers - connected_peers: Arc>, - // Push SPV status updates to ConnectionStatus (set after construction) - connection_status: Mutex>>, -} - -/// Requests that can be sent to the SPV runtime thread -/// -/// Note: These requests are handled in the same async context where the client lives, -/// allowing direct access to client methods without additional locking overhead. -enum SpvRequest { - BroadcastTransaction { - tx: Box, - response_tx: tokio::sync::oneshot::Sender>, - }, -} - -#[derive(Debug, Clone)] -pub struct SpvDerivedAddress { - pub address: Address, - pub derivation_path: DerivationPath, -} - -impl SpvManager { - // ==================== Lock Helper Methods ==================== - // These methods provide safe access to locks with proper error handling - // instead of panicking on lock poisoning. - - fn read_status(&self) -> SpvResult { - self.status - .read() - .map(|g| *g) - .map_err(|_| SpvError::LockPoisoned("status".into())) - } - - fn write_status(&self, value: SpvStatus) -> SpvResult<()> { - // NOTE: spawn_progress_watcher() bypasses this method because it runs in an - // async move closure that captures Arc> directly (no &self). - // If you add side-effects here, replicate them in spawn_progress_watcher() - // (around line ~1107). - let mut guard = self - .status - .write() - .map_err(|_| SpvError::LockPoisoned("status".into()))?; - *guard = value; - // Push every status transition to ConnectionStatus so the UI - // reflects changes immediately, without waiting for async watchers. - if let Some(cs) = self.connection_status_snapshot() { - cs.set_spv_status(value); - cs.refresh_state(); - } - Ok(()) - } - - fn read_last_error(&self) -> SpvResult> { - self.last_error - .read() - .map(|g| g.clone()) - .map_err(|_| SpvError::LockPoisoned("last_error".into())) - } - - fn write_last_error(&self, value: Option) -> SpvResult<()> { - // TODO: Push to ConnectionStatus here (like write_status does) to eliminate - // the scattered `cs.set_spv_last_error()` calls at each callsite. - // See: run_client exit (line ~451), sync manager error (line ~950), - // progress watcher (line ~1153), sync event handler (line ~1246). - let mut guard = self - .last_error - .write() - .map_err(|_| SpvError::LockPoisoned("last_error".into()))?; - *guard = value; - Ok(()) - } - - fn read_started_at(&self) -> SpvResult> { - self.started_at - .read() - .map(|g| *g) - .map_err(|_| SpvError::LockPoisoned("started_at".into())) - } - - fn write_started_at(&self, value: Option) -> SpvResult<()> { - let mut guard = self - .started_at - .write() - .map_err(|_| SpvError::LockPoisoned("started_at".into()))?; - *guard = value; - Ok(()) - } - - fn read_sync_progress(&self) -> SpvResult> { - self.sync_progress_state - .read() - .map(|g| g.clone()) - .map_err(|_| SpvError::LockPoisoned("sync_progress".into())) - } - - fn write_sync_progress(&self, value: Option) -> SpvResult<()> { - let mut guard = self - .sync_progress_state - .write() - .map_err(|_| SpvError::LockPoisoned("sync_progress".into()))?; - *guard = value; - Ok(()) - } - - fn read_progress_updated_at(&self) -> SpvResult> { - self.progress_updated_at - .read() - .map(|g| *g) - .map_err(|_| SpvError::LockPoisoned("progress_updated_at".into())) - } - - fn write_progress_updated_at(&self, value: Option) -> SpvResult<()> { - let mut guard = self - .progress_updated_at - .write() - .map_err(|_| SpvError::LockPoisoned("progress_updated_at".into()))?; - *guard = value; - Ok(()) - } - - // ==================== Public API ==================== - - pub fn new( - app_data_dir: &Path, - network: Network, - config: Arc>, - subtasks: Arc, - ) -> Result, String> { - let cfg = config.read().map_err(|e| e.to_string())?; - let data_dir = build_spv_data_dir(app_data_dir, network, &cfg)?; - drop(cfg); - fs::create_dir_all(&data_dir).map_err(|e| format!("Failed to create SPV data dir: {e}"))?; - - let manager = Arc::new(Self { - network, - data_dir, - config, - subtasks, - wallet: Arc::new(AsyncRwLock::new(WalletManager::::new( - network, - ))), - storage: Arc::new(Mutex::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)), - sync_progress_state: Arc::new(RwLock::new(None)), - progress_updated_at: Arc::new(RwLock::new(None)), - det_wallets: Arc::new(RwLock::new(std::collections::BTreeMap::new())), - reconcile_tx: Arc::new(Mutex::new(None)), - finality_tx: Arc::new(Mutex::new(None)), - use_local_node: Arc::new(AtomicBool::new(false)), - stop_token: Mutex::new(None), - request_tx: Mutex::new(None), - network_manager: Arc::new(AsyncRwLock::new(None)), - connected_peers: Arc::new(RwLock::new(0)), - connection_status: Mutex::new(None), - }); - - Ok(manager) - } - - /// Set whether to use local Dash Core node for SPV sync instead of DNS seed discovery. - /// Note: This only takes effect when starting a new SPV sync session. - pub fn set_use_local_node(&self, use_local: bool) { - self.use_local_node.store(use_local, Ordering::SeqCst); - } - - /// Get whether to use local Dash Core node for SPV sync. - pub fn use_local_node(&self) -> bool { - self.use_local_node.load(Ordering::SeqCst) - } - - /// Set the ConnectionStatus to receive push-based SPV status updates. - /// Must be called before `start()` so event handlers can push updates. - pub fn set_connection_status(&self, cs: Arc) { - if let Ok(mut guard) = self.connection_status.lock() { - *guard = Some(cs); - } - } - - fn connection_status_snapshot(&self) -> Option> { - self.connection_status.lock().ok().and_then(|g| g.clone()) - } - - /// Async status method for getting full details including progress. - /// Returns default snapshot on lock errors to avoid panics. - pub async fn status_async(&self) -> SpvStatusSnapshot { - let status = self.read_status().unwrap_or(SpvStatus::Idle); - let last_error = self.read_last_error().unwrap_or(None); - let started_at = self.read_started_at().unwrap_or(None); - let sync_progress = self.read_sync_progress().unwrap_or(None); - let last_updated = self - .read_progress_updated_at() - .unwrap_or(None) - .or(Some(SystemTime::now())); - let connected_peers = self.connected_peers.read().map(|g| *g).unwrap_or(0); - - SpvStatusSnapshot { - status, - sync_progress, - last_error, - started_at, - last_updated, - connected_peers, - } - } - - /// Sync status method for UI updates (doesn't fetch detailed progress). - /// Returns default snapshot on lock errors to avoid panics. - pub fn status(&self) -> SpvStatusSnapshot { - let status = self.read_status().unwrap_or(SpvStatus::Idle); - let last_error = self.read_last_error().unwrap_or(None); - let started_at = self.read_started_at().unwrap_or(None); - let sync_progress = self.read_sync_progress().unwrap_or(None); - let last_updated = self - .read_progress_updated_at() - .unwrap_or(None) - .or(Some(SystemTime::now())); - let connected_peers = self.connected_peers.read().map(|g| *g).unwrap_or(0); - - SpvStatusSnapshot { - status, - sync_progress, - last_error, - started_at, - last_updated, - connected_peers, - } - } - - pub fn start(self: &Arc, expected_wallet_count: usize) -> Result<(), String> { - // Check if already running - { - let stop_token_guard = self - .stop_token - .lock() - .map_err(|_| "SPV stop_token lock poisoned")?; - if stop_token_guard.is_some() { - return Ok(()); - } - } - - self.write_status(SpvStatus::Starting) - .map_err(|e| e.to_string())?; - self.write_last_error(None).map_err(|e| e.to_string())?; - self.write_started_at(Some(SystemTime::now())) - .map_err(|e| e.to_string())?; - self.write_sync_progress(None).map_err(|e| e.to_string())?; - self.write_progress_updated_at(None) - .map_err(|e| e.to_string())?; - - // Derive stop_token as a child of the global cancellation token so that - // global shutdown automatically cancels SPV without requiring an explicit - // SpvManager::stop() call. SpvManager::stop() can still cancel it early. - let stop_token = self.subtasks.cancellation_token.child_token(); - { - let mut guard = self - .stop_token - .lock() - .map_err(|_| "SPV stop_token lock poisoned")?; - *guard = Some(stop_token.clone()); - } - - let manager = Arc::clone(self); - - // Spawn SPV loop as a task on the main tokio runtime - self.subtasks.spawn_sync("spv_main_loop", async move { - let manager_for_loop = Arc::clone(&manager); - if let Err(err) = manager_for_loop - .run_spv_loop(stop_token, expected_wallet_count) - .await - { - tracing::error!(error = %err, network = ?manager.network, "SPV runtime failed"); - if let Err(e) = manager.write_last_error(Some(err.clone())) { - tracing::error!("Failed to write SPV error: {}", e); - } - if let Err(e) = manager.write_status(SpvStatus::Error) { - tracing::error!("Failed to write SPV status: {}", e); - } - // Push last_error separately — write_status already pushed the status. - if let Some(cs) = manager.connection_status_snapshot() { - cs.set_spv_last_error(Some(err)); - } - } - - // Clean up on exit - if let Ok(mut guard) = manager.stop_token.lock() { - *guard = None; - } - }); - - Ok(()) - } - - pub fn stop(&self) { - tracing::info!("SpvManager::stop() called"); - let maybe_token = self.stop_token.lock().ok().and_then(|g| g.clone()); - - if let Some(token) = maybe_token { - tracing::info!("SpvManager::stop(): cancelling stop_token, setting Stopping"); - let _ = self.write_status(SpvStatus::Stopping); - token.cancel(); - } else { - tracing::debug!("SpvManager::stop(): no stop_token, setting Stopped immediately"); - let _ = self.write_status(SpvStatus::Stopped); - } - } - - pub fn wallet(&self) -> Arc>> { - Arc::clone(&self.wallet) - } - - pub fn det_wallets_snapshot(&self) -> std::collections::BTreeMap<[u8; 32], WalletId> { - self.det_wallets - .read() - .map(|m| m.clone()) - .unwrap_or_default() - } - - pub fn wallet_id_for_seed(&self, seed_hash: WalletSeedHash) -> Option { - self.det_wallets - .read() - .ok() - .and_then(|map| map.get(&seed_hash).copied()) - } - - pub async fn unload_wallet(&self, seed_hash: WalletSeedHash) -> Result<(), String> { - let wallet_id = { - let map = self.det_wallets.read().map_err(|e| e.to_string())?; - map.get(&seed_hash).copied() - }; - - let Some(wallet_id) = wallet_id else { - return Ok(()); - }; - - let mut wm = self.wallet.write().await; - match wm.remove_wallet(&wallet_id) { - Ok((_wallet, _info)) => { - drop(wm); - let mut map = self.det_wallets.write().map_err(|e| e.to_string())?; - map.remove(&seed_hash); - Ok(()) - } - Err(WalletError::WalletNotFound(_)) => Ok(()), - Err(err) => Err(format!("Failed to unload SPV wallet: {err}")), - } - } - - pub async fn broadcast_transaction(&self, tx: &Transaction) -> Result<(), String> { - let request_tx = self - .request_tx - .lock() - .map_err(|_| "SPV request_tx lock poisoned")? - .clone() - .ok_or_else(|| "SPV client not running".to_string())?; - - let (response_tx, response_rx) = tokio::sync::oneshot::channel(); - - request_tx - .send(SpvRequest::BroadcastTransaction { - tx: Box::new(tx.clone()), - response_tx, - }) - .await - .map_err(|_| "SPV runtime channel closed".to_string())?; - - response_rx - .await - .map_err(|_| "SPV request cancelled".to_string())? - } - - /// Create a reconciliation signal channel for external listeners. - /// Returns a receiver that will get a signal when SPV wallet state likely changed. - /// - /// Only one subscriber is supported at a time. Calling this again replaces - /// the previous sender, so the earlier receiver will stop receiving signals. - pub fn register_reconcile_channel(&self) -> mpsc::Receiver<()> { - let (tx, rx) = mpsc::channel(64); - if let Ok(mut guard) = self.reconcile_tx.lock() { - *guard = Some(tx); - } - rx - } - - /// Create a finality event channel for external listeners. - /// Returns a receiver that will get events when SPV detects instant locks or chain locks. - /// - /// Only one subscriber is supported at a time. Calling this again replaces - /// the previous sender, so the earlier receiver will stop receiving events. - pub(crate) fn register_finality_channel(&self) -> mpsc::Receiver { - let (tx, rx) = mpsc::channel(64); - if let Ok(mut guard) = self.finality_tx.lock() { - *guard = Some(tx); - } - rx - } - - /// Remove all cached SPV data on disk for the current network. - /// - /// This requires the SPV runtime to be stopped first; otherwise the - /// on-disk files could be re-created immediately by the running client. - pub fn clear_data_dir(&self) -> Result<(), String> { - let status = self.read_status().map_err(|e| e.to_string())?; - if status.is_active() { - return Err("Stop the SPV client before clearing its data".to_string()); - } - - if let Ok(mut storage_guard) = self.storage.lock() { - *storage_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() { - *request_guard = None; - } - - if let Ok(mut wallet_map) = self.det_wallets.write() { - wallet_map.clear(); - } - - // Reset the in-memory WalletManager's synced_height so the next SPV session - // scans filters from genesis instead of the stale height from the previous run. - match self.wallet.try_write() { - Ok(mut wm) => { - wm.update_synced_height(0); - } - Err(_) => { - tracing::warn!("Failed to reset WalletManager synced_height during SPV data clear"); - } - } - - self.write_sync_progress(None).map_err(|e| e.to_string())?; - self.write_progress_updated_at(None) - .map_err(|e| e.to_string())?; - self.write_started_at(None).map_err(|e| e.to_string())?; - self.write_last_error(None).map_err(|e| e.to_string())?; - self.write_status(SpvStatus::Idle) - .map_err(|e| e.to_string())?; - if let Ok(mut guard) = self.connected_peers.write() { - *guard = 0; - } - - if self.data_dir.exists() { - fs::remove_dir_all(&self.data_dir).map_err(|e| { - format!( - "Failed to clear SPV data directory {}: {e}", - self.data_dir.display() - ) - })?; - } - - fs::create_dir_all(&self.data_dir).map_err(|e| { - format!( - "Failed to re-create SPV data directory {}: {e}", - self.data_dir.display() - ) - })?; - - Ok(()) - } - - /// Attempt to resolve a quorum public key via the SPV client's masternode/quorum state. - /// - /// 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, - quorum_hash: [u8; 32], - core_chain_locked_height: u32, - ) -> Result<[u8; 48], String> { - tracing::debug!( - "get_quorum_public_key called: type={}, hash={}, height={}", - quorum_type, - hex::encode(quorum_hash), - core_chain_locked_height - ); - - let client = { - let guard = self - .spv_client - .read() - .map_err(|e| format!("spv_client 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(); - - tracing::debug!( - "SPV quorum public key lookup in progress: type={}, hash={}, height={}", - quorum_type, - hex::encode(quorum_hash), - core_chain_locked_height - ); - - tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(async { - client - .get_quorum_at_height(core_chain_locked_height, llmq_type, qh) - .await - .map(|q| { - tracing::debug!( - "Quorum public key found: type={}, hash={}, height={}", - quorum_type, - hex::encode(quorum_hash), - core_chain_locked_height - ); - *q.quorum_entry.quorum_public_key.as_ref() - }) - .map_err(|e| { - tracing::warn!( - "Quorum lookup failed at height {} for llmq_type={} hash=0x{}: {}", - core_chain_locked_height, - quorum_type, - hex::encode(quorum_hash), - e - ); - e.to_string() - }) - }) - }) - } - - pub async fn load_wallet_from_seed( - &self, - seed_hash: WalletSeedHash, - mut seed_bytes: [u8; 64], - ) -> Result { - let existing_wallet_id = { - let map = self.det_wallets.read().map_err(|e| e.to_string())?; - map.get(&seed_hash).copied() - }; - - let mut wm = self.wallet.write().await; - - if let Some(wallet_id) = existing_wallet_id { - if let Some(wallet) = wm.get_wallet(&wallet_id) - && wallet.can_sign() - { - seed_bytes.zeroize(); - return Ok(wallet_id); - } - - if let Err(err) = wm.remove_wallet(&wallet_id) { - tracing::warn!(wallet = %hex::encode(wallet_id), ?err, "Failed to remove existing SPV wallet before upgrade"); - } else { - tracing::info!(wallet = %hex::encode(wallet_id), "Upgrading SPV wallet from watch-only to full access"); - } - } - - let xprv = ExtendedPrivKey::new_master(self.network, &seed_bytes).map_err(|e| { - seed_bytes.zeroize(); - format!("ExtendedPrivKey::new_master failed: {e}") - })?; - seed_bytes.zeroize(); - let mut xprv_str = xprv.to_string(); - - let account_options = Self::default_account_creation_options(); - - let wallet_id = match wm.import_wallet_from_extended_priv_key(&xprv_str, account_options) { - Ok(id) => { - xprv_str.zeroize(); - id - } - Err(WalletError::WalletExists(id)) => { - xprv_str.zeroize(); - id - } - Err(err) => { - xprv_str.zeroize(); - return Err(format!( - "import_wallet_from_extended_priv_key failed: {err}" - )); - } - }; - - drop(wm); - - let mut map = self.det_wallets.write().map_err(|e| e.to_string())?; - map.insert(seed_hash, wallet_id); - - Ok(wallet_id) - } - - pub async fn next_bip44_receive_address( - &self, - seed_hash: WalletSeedHash, - account_index: u32, - ) -> Result { - let wallet_id = { - let map = self.det_wallets.read().map_err(|e| e.to_string())?; - map.get(&seed_hash) - .copied() - .ok_or_else(|| "Wallet seed not loaded into SPV".to_string())? - }; - - let mut wm = self.wallet.write().await; - - let result = wm - .get_receive_address( - &wallet_id, - account_index, - AccountTypePreference::BIP44, - true, - ) - .map_err(|e| format!("get_receive_address failed: {e}"))?; - - let address = result - .address - .ok_or_else(|| "Wallet manager did not return an address".to_string())?; - - let derivation_path = { - let info = wm - .get_wallet_info(&wallet_id) - .ok_or_else(|| "wallet info missing".to_string())?; - let collection = info.accounts(); - let account = collection - .standard_bip44_accounts - .get(&account_index) - .ok_or_else(|| "BIP44 account missing".to_string())?; - let metadata = account - .get_address_info(&address) - .ok_or_else(|| "Address metadata unavailable".to_string())?; - metadata.path - }; - - Ok(SpvDerivedAddress { - address, - derivation_path, - }) - } - - fn default_account_creation_options() -> WalletAccountCreationOptions { - WalletAccountCreationOptions::Default - } - - async fn run_spv_loop( - self: Arc, - stop_token: CancellationToken, - expected_wallet_count: usize, - ) -> Result<(), String> { - // Wait for all expected wallets to be fully loaded into the WalletManager - // before building the client. Wallet loading happens via queue_spv_wallet_load - // which calls import_wallet_from_extended_priv_key (derives all accounts and - // addresses) under a write lock. Once wallet_count() reaches the expected - // value, all addresses are derived and monitored_addresses() is populated. - if expected_wallet_count > 0 { - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30); - loop { - { - let wm = self.wallet.read().await; - let loaded = wm.wallet_count(); - if loaded >= expected_wallet_count { - let addr_count = wm.monitored_addresses().len(); - tracing::info!( - expected = expected_wallet_count, - loaded, - addresses = addr_count, - "SPV: all wallets loaded with monitored addresses, proceeding" - ); - break; - } - } - if tokio::time::Instant::now() >= deadline { - let wm = self.wallet.read().await; - tracing::warn!( - expected = expected_wallet_count, - loaded = wm.wallet_count(), - "SPV: timed out waiting for all wallets to load, proceeding anyway" - ); - break; - } - if stop_token.is_cancelled() { - return Ok(()); - } - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - } - } - - // Build the client (run() will call start() internally) - let has_wallets = expected_wallet_count > 0; - let client = self.build_client(has_wallets).await?; - - // Store the shared storage reference for later access - { - let storage = client.storage(); - if let Ok(mut storage_guard) = self.storage.lock() { - *storage_guard = Some(storage); - } - } - - // Subscribe to wallet events (broadcast from WalletManager) - // Note: sync/network/progress events are now dispatched internally by - // DashSpvClient via the EventHandler trait — no manual subscription needed. - { - let wm = self.wallet.read().await; - let wallet_rx = wm.subscribe_events(); - self.spawn_wallet_event_handler(wallet_rx); - } - - // Set up request handler with access to shared components - let (request_tx, request_rx) = mpsc::channel(32); - { - if let Ok(mut guard) = self.request_tx.lock() { - *guard = Some(request_tx); - } - } - - // Spawn request handler in a separate task - self.spawn_request_handler(request_rx, stop_token.clone()); - - // Store a clone of the client for external queries (quorum lookups, etc.) - { - let mut guard = self - .spv_client - .write() - .map_err(|e| format!("spv_client lock poisoned: {e}"))?; - *guard = Some(client.clone()); - } - - let _ = self.write_status(SpvStatus::Syncing); - - // Run sync and monitor with the client owned in this scope - let result = self.clone().run_client(client, stop_token).await; - - // Clear the client reference and network manager since the client is done - { - if let Ok(mut guard) = self.spv_client.write() { - *guard = None; - } - } - { - let mut nm_guard = self.network_manager.write().await; - *nm_guard = None; - } - if let Ok(mut guard) = self.connected_peers.write() { - *guard = 0; - } - if let Some(cs) = self.connection_status_snapshot() { - cs.set_spv_connected_peers(0); - } - { - // Drop shared storage/request handles so the disk lock is released before restart. - if let Ok(mut storage_guard) = self.storage.lock() { - *storage_guard = None; - } - if let Ok(mut guard) = self.request_tx.lock() { - *guard = None; - } - } - - result - } - - async fn run_client( - self: Arc, - client: SpvClient, - stop_token: CancellationToken, - ) -> Result<(), String> { - // 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 { - RunCompleted(Result<(), dash_sdk::dash_spv::SpvError>), - Cancelled, - } - - let outcome = { - 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 run_future => Outcome::RunCompleted(result), - _ = stop_token.cancelled() => { - run_cancel.cancel(); - Outcome::Cancelled - }, - } - }; // run_future is dropped here, releasing the borrow - - tracing::info!( - "run_client: outcome = {}", - match &outcome { - Outcome::RunCompleted(Ok(())) => "RunCompleted(Ok)", - Outcome::RunCompleted(Err(_)) => "RunCompleted(Err)", - Outcome::Cancelled => "Cancelled", - } - ); - - match outcome { - Outcome::RunCompleted(Ok(())) => { - let _ = self.write_status(SpvStatus::Stopped); - } - 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); - // Push last_error separately — write_status already pushed the status. - if let Some(cs) = self.connection_status_snapshot() { - cs.set_spv_last_error(Some(message.clone())); - } - } - Outcome::Cancelled => { - let _ = self.write_status(SpvStatus::Stopped); - } - } - Ok(()) - } - - fn spawn_request_handler( - &self, - mut request_rx: mpsc::Receiver, - cancel: CancellationToken, - ) { - 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! { - _ = cancel.cancelled() => { - tracing::info!("SPV request handler cancelled"); - break; - } - request = request_rx.recv() => { - match request { - Some(SpvRequest::BroadcastTransaction { tx, response_tx }) => { - tracing::debug!("Received BroadcastTransaction request"); - let result = { - let nm_guard = network_manager.read().await; - if let Some(ref nm) = *nm_guard { - // Broadcast the transaction to all connected peers - let message = dash_sdk::dpp::dashcore::network::message::NetworkMessage::Tx((*tx).clone()); - let results = nm.broadcast(message).await; - // Check if at least one broadcast succeeded - let mut success = false; - let mut errors = Vec::new(); - for res in results { - match res { - Ok(_) => success = true, - Err(e) => errors.push(e.to_string()), - } - } - if success { - tracing::info!("Transaction {} broadcast successfully", tx.txid()); - Ok(()) - } else if errors.is_empty() { - Err("No peers connected to broadcast transaction".to_string()) - } else { - Err(format!("Broadcast failed: {}", errors.join(", "))) - } - } else { - 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 => { - tracing::warn!("SPV request channel closed"); - break; - } - } - } - } - } - tracing::info!("SPV request handler exiting"); - }); - } - - /// 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_wallet_event_handler( - &self, - mut wallet_rx: tokio::sync::broadcast::Receiver, - ) { - let reconcile_tx = self.reconcile_tx.lock().ok().and_then(|g| g.clone()); - let cancel = self.subtasks.cancellation_token.clone(); - - self.subtasks - .spawn_sync("spv_wallet_event_handler", async move { - loop { - tokio::select! { - _ = cancel.cancelled() => break, - result = wallet_rx.recv() => { - match result { - Ok(_event) => { - // All wallet events trigger reconcile - if let Some(ref tx) = reconcile_tx { - let _ = tx.try_send(()); - } - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { - tracing::warn!("Wallet event handler lagged by {} events", n); - // Still trigger reconcile to catch up - if let Some(ref tx) = reconcile_tx { - let _ = tx.try_send(()); - } - } - Err(tokio::sync::broadcast::error::RecvError::Closed) => break, - } - } - } - } - tracing::info!("SPV wallet event handler exiting"); - }); - } - - async fn build_client(&self, has_wallets: bool) -> Result { - // When wallets exist, scan from genesis so historical transactions are found via - // compact block filters. When no wallets are loaded, skip to chain tip (u32::MAX) - // to avoid unnecessary work. We check both the caller hint and the actual wallet - // count (the wait loop in run_spv_loop should have ensured loading completed). - let wallet_count = self.wallet.read().await.wallet_count(); - let start_height = if has_wallets || wallet_count > 0 { - 0 - } else { - u32::MAX - }; - let mut config = ClientConfig::new(self.network) - .with_storage_path(self.data_dir.clone()) - .with_validation_mode(ValidationMode::Full) - .with_start_height(start_height) - .with_mempool_tracking(MempoolStrategy::BloomFilter); - - // Configure peer discovery based on network type and user preference. - // Devnet/Regtest always need explicit peers since they're local networks. - // Mainnet/Testnet can use DNS seed discovery (default) or local node. - if self.network == Network::Devnet || self.network == Network::Regtest { - // Local networks always need explicit peer configuration - if let Some(peer) = self.primary_peer_socket() { - config.add_peer(peer); - } - } else if self.use_local_node() { - // User has chosen to use their local Dash Core node - if let Some(peer) = self.primary_peer_socket() { - config.add_peer(peer); - } - } - // Otherwise, no peers are added and SPV will use DNS seed discovery - - let network_manager = PeerNetworkManager::new(&config) - .await - .map_err(|e| format!("Failed to initialize SPV network manager: {e}"))?; - - // Store a clone of the network manager for broadcasting transactions - { - let mut nm_guard = self.network_manager.write().await; - *nm_guard = Some(network_manager.clone()); - } - - let storage_manager = DiskStorageManager::new(&config) - .await - .map_err(|e| format!("Failed to initialize SPV storage: {e}"))?; - - let event_handler = Arc::new(SpvEventHandler { - sync_progress_state: Arc::clone(&self.sync_progress_state), - progress_updated_at: Arc::clone(&self.progress_updated_at), - status: Arc::clone(&self.status), - last_error: Arc::clone(&self.last_error), - connected_peers: Arc::clone(&self.connected_peers), - connection_status: self.connection_status_snapshot(), - reconcile_tx: Arc::clone(&self.reconcile_tx), - finality_tx: Arc::clone(&self.finality_tx), - }); - - DashSpvClient::new( - config, - network_manager, - storage_manager, - Arc::clone(&self.wallet), - event_handler, - ) - .await - .map_err(|e| format!("Failed to create SPV client: {e}")) - } - - fn primary_peer_socket(&self) -> Option { - let config = self.config.read().ok()?; - - let host = config.core_host.as_deref()?; - let port = match self.network { - Network::Mainnet => 9999, - Network::Testnet => 19999, - Network::Devnet => 20001, - Network::Regtest => 19899, - _ => 9999, - }; - - let addr = format!("{}:{}", host, port); - addr.to_socket_addrs().ok()?.next() - } -} - -fn build_spv_data_dir( - app_data_dir: &Path, - network: Network, - config: &NetworkConfig, -) -> Result { - let mut base = app_data_dir.to_path_buf(); - base.push("spv"); - fs::create_dir_all(&base).map_err(|e| format!("Failed to create SPV base dir: {e}"))?; - - let network_dir = match network { - Network::Mainnet => "mainnet".to_string(), - Network::Testnet => "testnet".to_string(), - Network::Devnet => { - let name = config - .devnet_name - .clone() - .unwrap_or_else(|| "devnet".to_string()); - // Sanitize to prevent path traversal (e.g. "../../etc") - let sanitized: String = name - .chars() - .map(|c| { - if c.is_alphanumeric() || c == '-' || c == '_' { - c - } else { - '_' - } - }) - .collect(); - if sanitized.is_empty() { - "devnet".to_string() - } else { - sanitized - } - } - Network::Regtest => "regtest".to_string(), - other => format!("{other:?}"), - }; - - Ok(base.join(network_dir)) -} - -/// Workaround for [dashpay/rust-dashcore#487]: upstream `broadcast_transaction()` does not -/// call `process_mempool_transaction()` on the local wallet, so spent UTXOs stay unmarked -/// and the balance is stale until a block is mined. -/// -/// Remove this function once the upstream fix lands. -/// -/// [dashpay/rust-dashcore#487]: https://github.com/dashpay/rust-dashcore/issues/487 -async fn notify_wallet_after_broadcast( - wallet: &Arc>>, - tx: &Transaction, - reconcile_tx: Option<&mpsc::Sender<()>>, -) { - { - let mut wm = wallet.write().await; - let _ = wm.process_mempool_transaction(tx, false).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") - .field("network", &self.network) - .field("data_dir", &self.data_dir) - .finish() - } -} diff --git a/src/spv/mod.rs b/src/spv/mod.rs index 5db1179c7..d5054969a 100644 --- a/src/spv/mod.rs +++ b/src/spv/mod.rs @@ -1,8 +1,8 @@ mod error; -pub(crate) mod manager; +pub mod event_bridge; #[cfg(test)] mod tests; +pub mod types; pub use error::{SpvError, SpvResult}; -pub(crate) use manager::AssetLockFinalityEvent; -pub use manager::{CoreBackendMode, SpvDerivedAddress, SpvManager, SpvStatus, SpvStatusSnapshot}; +pub use types::{CoreBackendMode, SpvStatus, SpvStatusSnapshot}; diff --git a/src/spv/tests.rs b/src/spv/tests.rs index 7b75baed9..86e3ba43f 100644 --- a/src/spv/tests.rs +++ b/src/spv/tests.rs @@ -1,386 +1,10 @@ -//! Integration tests for SpvManager lifecycle, concurrency, and state transitions. +//! SPV status and types tests. +//! +//! TODO: The SpvManager integration tests (lifecycle, concurrency, +//! state transitions) need to be rewritten to test PlatformWalletManager + +//! SpvRuntime + SpvEventBridge instead of the deleted SpvManager. -use crate::config::NetworkConfig; use crate::spv::SpvStatus; -use crate::spv::manager::SpvManager; -use crate::utils::tasks::TaskManager; -use dash_sdk::dpp::dashcore::Network; -use std::sync::{Arc, RwLock}; -use tokio::time::{Duration, timeout}; - -/// Deadlock detection timeout: if any operation takes longer than this, -/// the test fails (likely a deadlock). -const DEADLOCK_TIMEOUT: Duration = Duration::from_secs(10); - -/// Create a minimal testnet NetworkConfig for testing. -fn test_network_config() -> NetworkConfig { - NetworkConfig { - dapi_addresses: Some("https://127.0.0.1:1443".to_string()), - core_host: Some("127.0.0.1".to_string()), - core_rpc_port: Some(19998), - core_rpc_user: Some("dashrpc".to_string()), - core_rpc_password: Some("password".to_string()), - core_zmq_endpoint: Some("tcp://127.0.0.1:23709".to_string()), - devnet_name: None, - wallet_private_key: None, - } -} - -/// Create an SpvManager for testing. Uses testnet config and a fresh TaskManager. -/// Returns the `TempDir` so it stays alive for the test duration. -fn create_test_manager() -> (Arc, Arc, tempfile::TempDir) { - let config = Arc::new(RwLock::new(test_network_config())); - let task_manager = Arc::new(TaskManager::new()); - let tmp_dir = tempfile::TempDir::new().expect("Failed to create temp dir"); - let manager = SpvManager::new( - tmp_dir.path(), - Network::Testnet, - config, - task_manager.clone(), - ) - .expect("SpvManager::new should succeed"); - // Use explicit local peer to avoid DNS seed discovery which hangs in tests - // (upstream bug: dashpay/rust-dashcore#577). - manager.set_use_local_node(true); - (manager, task_manager, tmp_dir) -} - -// ── Construction and initial state ─────────────────────────────── - -/// Given a freshly constructed SpvManager, -/// When reading the status snapshot, -/// Then status is Idle, no error, no start time, no progress, and 0 peers. -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_new_manager_has_idle_status() { - let (manager, _tm, _tmp_dir) = create_test_manager(); - let snapshot = manager.status(); - assert_eq!( - snapshot.status, - SpvStatus::Idle, - "New manager should be Idle" - ); - assert!( - snapshot.last_error.is_none(), - "New manager should have no error" - ); - assert!( - snapshot.started_at.is_none(), - "New manager should have no started_at" - ); - assert!( - snapshot.sync_progress.is_none(), - "New manager should have no sync progress" - ); - assert_eq!( - snapshot.connected_peers, 0, - "New manager should have 0 connected peers" - ); -} - -/// Given a freshly constructed SpvManager, -/// When taking multiple sync and async snapshots in sequence, -/// Then all snapshots are consistent (Idle, no error, 0 peers). -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_status_snapshot_consistency() { - let (manager, _tm, _tmp_dir) = create_test_manager(); - - for _ in 0..10 { - let sync_snapshot = manager.status(); - let async_snapshot = manager.status_async().await; - - assert_eq!(sync_snapshot.status, SpvStatus::Idle); - assert_eq!(async_snapshot.status, SpvStatus::Idle); - assert!(sync_snapshot.last_error.is_none()); - assert!(async_snapshot.last_error.is_none()); - assert_eq!(sync_snapshot.connected_peers, 0); - assert_eq!(async_snapshot.connected_peers, 0); - } -} - -// ── Stop when idle ─────────────────────────────────────────────── - -/// Given an idle SpvManager that has never been started, -/// When calling stop(), -/// Then it completes without panic or deadlock and sets status to Stopped. -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_stop_when_idle_does_not_panic() { - let (manager, _tm, _tmp_dir) = create_test_manager(); - - let result = timeout(DEADLOCK_TIMEOUT, async { - manager.stop(); - }) - .await; - assert!( - result.is_ok(), - "stop() should complete within timeout (no deadlock)" - ); - - let snapshot = manager.status(); - assert_eq!( - snapshot.status, - SpvStatus::Stopped, - "stop() on idle manager should set status to Stopped" - ); -} - -/// Given an idle SpvManager, -/// When calling stop() twice in succession, -/// Then both calls complete without panic or deadlock. -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_double_stop_does_not_panic() { - let (manager, _tm, _tmp_dir) = create_test_manager(); - - let result = timeout(DEADLOCK_TIMEOUT, async { - manager.stop(); - manager.stop(); - }) - .await; - assert!( - result.is_ok(), - "Double stop() should complete within timeout" - ); - - let snapshot = manager.status(); - assert_eq!(snapshot.status, SpvStatus::Stopped); -} - -// ── use_local_node flag ────────────────────────────────────────── - -/// Given a freshly constructed SpvManager, -/// When toggling use_local_node on and off, -/// Then the getter reflects each change correctly. -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_use_local_node_toggle() { - let (manager, _tm, _tmp_dir) = create_test_manager(); - - assert!( - manager.use_local_node(), - "Test helper sets use_local_node to true" - ); - manager.set_use_local_node(false); - assert!(!manager.use_local_node(), "Should be false after clear"); - manager.set_use_local_node(true); - assert!(manager.use_local_node(), "Should be true after set"); -} - -// ── clear_data_dir when idle ───────────────────────────────────── - -/// Given an idle SpvManager, -/// When calling clear_data_dir(), -/// Then it succeeds and the status remains Idle. -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_clear_data_dir_when_idle() { - let (manager, _tm, _tmp_dir) = create_test_manager(); - - let result = timeout(DEADLOCK_TIMEOUT, async { manager.clear_data_dir() }).await; - assert!( - result.is_ok(), - "clear_data_dir() should complete within timeout" - ); - let clear_result = result.unwrap(); - assert!( - clear_result.is_ok(), - "clear_data_dir() should succeed when idle: {:?}", - clear_result.err() - ); - - let snapshot = manager.status(); - assert_eq!(snapshot.status, SpvStatus::Idle); -} - -// ── Concurrent status reads ────────────────────────────────────── - -/// Given an idle SpvManager shared across 20 concurrent tasks, -/// When each task reads the status (sync and async) 100 times, -/// Then all reads complete within the deadlock timeout without panic. -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_concurrent_status_reads_no_deadlock() { - let (manager, _tm, _tmp_dir) = create_test_manager(); - - let result = timeout(DEADLOCK_TIMEOUT, async { - let mut handles = Vec::new(); - for _ in 0..20 { - let mgr = Arc::clone(&manager); - handles.push(tokio::spawn(async move { - for _ in 0..100 { - let _snapshot = mgr.status(); - let _async_snapshot = mgr.status_async().await; - tokio::task::yield_now().await; - } - })); - } - for handle in handles { - handle.await.expect("Task should not panic"); - } - }) - .await; - - assert!( - result.is_ok(), - "Concurrent status reads should complete within timeout (no deadlock)" - ); -} - -// ── Start lifecycle (no network) ───────────────────────────────── - -/// Given an idle SpvManager with no wallets, -/// When calling start(0), -/// Then it returns Ok and the status transitions to Starting (or Syncing/Error -/// if the background task progresses before the assertion). -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_start_sets_starting_status() { - let (manager, tm, _tmp_dir) = create_test_manager(); - - let start_result = manager.start(0); - assert!( - start_result.is_ok(), - "start() should return Ok: {:?}", - start_result.err() - ); - - let snapshot = manager.status(); - assert!( - snapshot.status == SpvStatus::Starting - || snapshot.status == SpvStatus::Syncing - || snapshot.status == SpvStatus::Error, - "After start(), status should be Starting, Syncing, or Error (if network fails fast), got: {:?}", - snapshot.status - ); - - manager.stop(); - tokio::time::sleep(Duration::from_millis(200)).await; - let _ = tm.shutdown(); -} - -/// Given an already-started SpvManager, -/// When calling start() a second time, -/// Then it returns Ok without spawning a duplicate loop (idempotent). -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_double_start_is_idempotent() { - let (manager, tm, _tmp_dir) = create_test_manager(); - - let first = manager.start(0); - assert!(first.is_ok(), "First start() should succeed"); - - tokio::time::sleep(Duration::from_millis(100)).await; - - let second = manager.start(0); - assert!(second.is_ok(), "Second start() should succeed (idempotent)"); - - manager.stop(); - tokio::time::sleep(Duration::from_millis(200)).await; - let _ = tm.shutdown(); -} - -// ── Start + Stop lifecycle (clean shutdown) ────────────────────── - -/// Given a started SpvManager, -/// When calling stop() and polling for completion, -/// Then the status reaches Stopped (or Error) within the deadlock timeout. -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_start_stop_clean_shutdown() { - let (manager, tm, _tmp_dir) = create_test_manager(); - - manager.start(0).expect("start() should succeed"); - - tokio::time::sleep(Duration::from_millis(100)).await; - - let result = timeout(DEADLOCK_TIMEOUT, async { - manager.stop(); - for _ in 0..100 { - let snapshot = manager.status(); - if snapshot.status == SpvStatus::Stopped || snapshot.status == SpvStatus::Error { - return snapshot.status; - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - manager.status().status - }) - .await; - - assert!( - result.is_ok(), - "Stop should complete within timeout (no deadlock)" - ); - let final_status = result.unwrap(); - assert!( - final_status == SpvStatus::Stopped || final_status == SpvStatus::Error, - "After stop(), status should be Stopped or Error, got: {:?}", - final_status - ); - - let _ = tm.shutdown(); -} - -// ── Rapid start/stop ───────────────────────────────────────────── - -/// Given a SpvManager, -/// When performing 5 rapid start/stop cycles, -/// Then all cycles complete without panic or deadlock. -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_rapid_start_stop_no_panic() { - let (manager, tm, _tmp_dir) = create_test_manager(); - - let result = timeout(DEADLOCK_TIMEOUT, async { - for _ in 0..5 { - let _ = manager.start(0); - tokio::time::sleep(Duration::from_millis(50)).await; - manager.stop(); - tokio::time::sleep(Duration::from_millis(200)).await; - } - }) - .await; - - assert!( - result.is_ok(), - "Rapid start/stop cycles should complete within timeout (no deadlock or panic)" - ); - - let _ = tm.shutdown(); -} - -// ── Concurrent status reads during start/stop ──────────────────── - -/// Given a SpvManager with 10 concurrent reader tasks, -/// When performing a start/stop lifecycle while readers are active, -/// Then all readers complete without panic or deadlock. -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_concurrent_reads_during_lifecycle() { - let (manager, tm, _tmp_dir) = create_test_manager(); - - let result = timeout(DEADLOCK_TIMEOUT, async { - let mut readers = Vec::new(); - for _ in 0..10 { - let mgr = Arc::clone(&manager); - readers.push(tokio::spawn(async move { - for _ in 0..50 { - let snapshot = mgr.status(); - let _ = snapshot.status.is_active(); - let _ = snapshot.last_error; - let _ = snapshot.connected_peers; - tokio::task::yield_now().await; - } - })); - } - - let _ = manager.start(0); - tokio::time::sleep(Duration::from_millis(100)).await; - manager.stop(); - tokio::time::sleep(Duration::from_millis(200)).await; - - for r in readers { - r.await.expect("Reader task should not panic"); - } - }) - .await; - - assert!( - result.is_ok(), - "Concurrent reads during lifecycle should complete without deadlock" - ); - - let _ = tm.shutdown(); -} // ── SpvStatus helper methods ───────────────────────────────────── @@ -416,87 +40,6 @@ async fn test_spv_status_from_u8_roundtrip() { assert_eq!(SpvStatus::from(7), SpvStatus::Idle); } -// ── Wallet operations on idle manager ──────────────────────────── - -/// Given a freshly constructed SpvManager with no wallets loaded, -/// When calling det_wallets_snapshot(), -/// Then the returned map is empty. -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_det_wallets_snapshot_empty() { - let (manager, _tm, _tmp_dir) = create_test_manager(); - let wallets = manager.det_wallets_snapshot(); - assert!(wallets.is_empty(), "New manager should have no wallets"); -} - -/// Given a freshly constructed SpvManager, -/// When looking up a wallet by an unknown seed hash, -/// Then None is returned. -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_wallet_id_for_seed_returns_none() { - let (manager, _tm, _tmp_dir) = create_test_manager(); - let seed_hash = [0u8; 32]; - assert!( - manager.wallet_id_for_seed(seed_hash).is_none(), - "Unknown seed hash should return None" - ); -} - -// ── Reconcile and finality channels ────────────────────────────── - -/// Given a freshly constructed SpvManager, -/// When registering a reconcile channel and immediately trying to receive, -/// Then the channel is open but empty (no signals yet). -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_register_reconcile_channel() { - let (manager, _tm, _tmp_dir) = create_test_manager(); - let mut rx = manager.register_reconcile_channel(); - - let result = rx.try_recv(); - assert!(result.is_err(), "Channel should be empty initially"); -} - -/// Given a freshly constructed SpvManager, -/// When registering a finality channel and immediately trying to receive, -/// Then the channel is open but empty (no events yet). -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_register_finality_channel() { - let (manager, _tm, _tmp_dir) = create_test_manager(); - let mut rx = manager.register_finality_channel(); - - let result = rx.try_recv(); - assert!(result.is_err(), "Channel should be empty initially"); -} - -// ── Broadcast transaction on idle manager ──────────────────────── - -/// Given an idle SpvManager that has not been started, -/// When attempting to broadcast a transaction, -/// Then the call fails with an error indicating SPV is not running. -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_broadcast_transaction_fails_when_not_running() { - let (manager, _tm, _tmp_dir) = create_test_manager(); - - let tx = dash_sdk::dpp::dashcore::Transaction { - version: 2, - lock_time: 0, - input: vec![], - output: vec![], - special_transaction_payload: None, - }; - - let result = manager.broadcast_transaction(&tx).await; - assert!( - result.is_err(), - "Broadcast should fail when SPV is not running" - ); - let err = result.unwrap_err(); - assert!( - err.contains("not running"), - "Error should mention not running, got: {}", - err - ); -} - // ── CoreBackendMode ────────────────────────────────────────────── /// Given CoreBackendMode variants, @@ -514,126 +57,23 @@ async fn test_core_backend_mode_roundtrip() { assert_eq!(CoreBackendMode::Spv.as_u8(), 1); } -// ── Live testnet sync ──────────────────────────────────────────── - -/// Parse `.env.example` from the project root and extract the TESTNET_ NetworkConfig. -fn load_testnet_config_from_env_example() -> NetworkConfig { - let manifest_dir = env!("CARGO_MANIFEST_DIR"); - let env_path = std::path::Path::new(manifest_dir).join(".env.example"); - assert!( - env_path.exists(), - ".env.example not found at {}", - env_path.display() - ); - - let contents = std::fs::read_to_string(&env_path).expect("Failed to read .env.example"); - - let mut vars = std::collections::HashMap::new(); - for line in contents.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - if let Some((key, value)) = line.split_once('=') { - vars.insert(key.to_string(), value.to_string()); - } - } - - envy::prefixed("TESTNET_") - .from_iter(vars) - .expect("Failed to parse TESTNET_ config from .env.example") -} - -/// Given an SpvManager configured with real testnet DAPI addresses from `.env.example`, -/// When starting SPV sync with no wallets, letting it sync for 10 seconds, then stopping, -/// Then at least one peer connects, sync progress is reported, and shutdown completes -/// within 15 seconds without deadlock. -#[ignore] // Requires network access to Dash testnet peers -#[tokio::test(flavor = "multi_thread", worker_threads = 12)] -async fn test_live_testnet_sync_and_shutdown() { - let testnet_config = load_testnet_config_from_env_example(); - let config = Arc::new(RwLock::new(testnet_config)); - let task_manager = Arc::new(TaskManager::new()); - let tmp_dir = tempfile::TempDir::new().expect("Failed to create temp dir"); - let manager = SpvManager::new( - tmp_dir.path(), - Network::Testnet, - config, - task_manager.clone(), - ) - .expect("SpvManager::new should succeed"); - - // Start SPV with no wallets (header-only sync to chain tip) - manager.start(0).expect("start() should succeed"); - - // Wait for peers to connect (up to 30s) - let connect_timeout = Duration::from_secs(30); - let connect_result = timeout(connect_timeout, async { - loop { - let snapshot = manager.status_async().await; - if snapshot.connected_peers > 0 { - return snapshot; - } - if snapshot.status == SpvStatus::Error - && let Some(ref err) = snapshot.last_error - { - eprintln!("SPV reported error during peer discovery: {}", err); - } - tokio::time::sleep(Duration::from_millis(500)).await; - } - }) - .await; - - assert!( - connect_result.is_ok(), - "Should connect to at least one testnet peer within 30s" - ); - let snapshot = connect_result.unwrap(); - eprintln!( - "Connected to {} peer(s), status: {:?}", - snapshot.connected_peers, snapshot.status - ); - - // Let the sync run for 10 seconds to exercise the full pipeline - eprintln!("Letting sync run for 10 seconds..."); - tokio::time::sleep(Duration::from_secs(10)).await; - - // Capture state after syncing - let snapshot = manager.status_async().await; - eprintln!( - "After 10s sync: status={:?}, peers={}, progress={:?}", - snapshot.status, snapshot.connected_peers, snapshot.sync_progress - ); - assert!( - snapshot.sync_progress.is_some(), - "Should have received sync progress after 10s of syncing" - ); - - // Shutdown must complete within 15 seconds -- timeout means deadlock - let shutdown_timeout = Duration::from_secs(15); - let shutdown_result = timeout(shutdown_timeout, async { - manager.stop(); - loop { - let snapshot = manager.status_async().await; - if snapshot.status == SpvStatus::Stopped || snapshot.status == SpvStatus::Error { - return snapshot.status; - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - }) - .await; - - assert!( - shutdown_result.is_ok(), - "Shutdown MUST complete within 15s -- timeout indicates a deadlock" - ); - let final_status = shutdown_result.unwrap(); - assert_eq!( - final_status, - SpvStatus::Stopped, - "Final status should be Stopped after clean shutdown, got: {:?}", - final_status - ); - - let _ = task_manager.shutdown(); -} +// TODO: Rewrite the following tests for PlatformWalletManager + SpvRuntime: +// +// - test_new_manager_has_idle_status +// - test_status_snapshot_consistency +// - test_stop_when_idle_does_not_panic +// - test_double_stop_does_not_panic +// - test_use_local_node_toggle +// - test_clear_data_dir_when_idle +// - test_concurrent_status_reads_no_deadlock +// - test_start_sets_starting_status +// - test_double_start_is_idempotent +// - test_start_stop_clean_shutdown +// - test_rapid_start_stop_no_panic +// - test_concurrent_reads_during_lifecycle +// - test_det_wallets_snapshot_empty +// - test_wallet_id_for_seed_returns_none +// - test_register_reconcile_channel +// - test_register_finality_channel +// - test_broadcast_transaction_fails_when_not_running +// - test_live_testnet_sync_and_shutdown diff --git a/src/spv/types.rs b/src/spv/types.rs new file mode 100644 index 000000000..b21878b93 --- /dev/null +++ b/src/spv/types.rs @@ -0,0 +1,132 @@ +//! Shared SPV types used across the event bridge and manager. +//! +//! Extracted from `manager.rs` so they survive manager deletion during +//! the platform-wallet migration. + +use dash_sdk::dash_spv::sync::SyncProgress as SpvSyncProgress; +use dash_sdk::dash_spv::sync::SyncState; +use std::time::SystemTime; + +/// High-level status of the SPV client runtime. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[repr(u8)] +pub enum SpvStatus { + #[default] + Idle = 0, + Starting = 1, + Syncing = 2, + Running = 3, + Stopping = 4, + Stopped = 5, + Error = 6, +} + +impl SpvStatus { + pub fn is_active(self) -> bool { + matches!( + self, + SpvStatus::Starting | SpvStatus::Syncing | SpvStatus::Running | SpvStatus::Stopping + ) + } +} + +impl std::fmt::Display for SpvStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SpvStatus::Idle => write!(f, "Idle"), + SpvStatus::Starting => write!(f, "Starting"), + SpvStatus::Syncing => write!(f, "Syncing"), + SpvStatus::Running => write!(f, "Running"), + SpvStatus::Stopping => write!(f, "Stopping"), + SpvStatus::Stopped => write!(f, "Stopped"), + SpvStatus::Error => write!(f, "Error"), + } + } +} + +impl From for SpvStatus { + fn from(value: u8) -> Self { + match value { + 0 => SpvStatus::Idle, + 1 => SpvStatus::Starting, + 2 => SpvStatus::Syncing, + 3 => SpvStatus::Running, + 4 => SpvStatus::Stopping, + 5 => SpvStatus::Stopped, + 6 => SpvStatus::Error, + _ => SpvStatus::Idle, + } + } +} + +/// Snapshot of the SPV runtime state for UI consumption. +/// Uses dash-spv's built-in progress types directly instead of duplicating. +#[derive(Debug, Clone, Default)] +pub struct SpvStatusSnapshot { + pub status: SpvStatus, + pub sync_progress: Option, + pub last_error: Option, + pub started_at: Option, + pub last_updated: Option, + pub connected_peers: usize, +} + +/// Preferred backend for Core-level operations. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum CoreBackendMode { + #[default] + Rpc = 0, + Spv = 1, +} + +impl CoreBackendMode { + pub fn as_u8(self) -> u8 { + self as u8 + } +} + +impl From for CoreBackendMode { + fn from(value: u8) -> Self { + match value { + 1 => CoreBackendMode::Spv, + _ => CoreBackendMode::Rpc, + } + } +} + +/// Identify which sync phase failed (for error messages). +/// +/// Identify which sync phase reported the error (for user-facing messages). +pub 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" +} diff --git a/src/ui/components/address_input.rs b/src/ui/components/address_input.rs index 89ba2cb1a..3a2028384 100644 --- a/src/ui/components/address_input.rs +++ b/src/ui/components/address_input.rs @@ -213,10 +213,10 @@ impl AddressInput { /// Provide wallet data for **Core and Platform** autocomplete only. /// - /// This extracts BIP44 (Core) addresses from `known_addresses` and - /// PlatformPayment addresses from `watched_addresses`. It does NOT - /// extract identities or shielded addresses — those live outside the - /// `Wallet` struct and must be added separately: + /// This extracts BIP44 (Core) addresses and PlatformPayment addresses + /// from PlatformWallet's CoreAddressInfo. It does NOT extract identities + /// or shielded addresses — those live outside the `Wallet` struct and + /// must be added separately: /// /// - **Identities**: call [`with_identities()`] with `QualifiedIdentity` /// data from `AppContext::load_local_qualified_identities()`. @@ -226,10 +226,14 @@ impl AddressInput { /// 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 { + pub fn with_wallets( + mut self, + wallets: &[Arc>], + db: Option<&crate::database::Database>, + ) -> Self { let multi = wallets.len() > 1; for wallet in wallets { - self.extract_wallet_entries(wallet, multi); + self.extract_wallet_entries(wallet, multi, db); } self } @@ -333,13 +337,17 @@ impl AddressInput { /// 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>]) { + pub fn set_wallets( + &mut self, + wallets: &[Arc>], + db: Option<&crate::database::Database>, + ) { 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); + self.extract_wallet_entries(wallet, multi, db); } } @@ -364,7 +372,12 @@ impl AddressInput { // --- Entry extraction --- - fn extract_wallet_entries(&mut self, wallet: &Arc>, multi_wallet: bool) { + fn extract_wallet_entries( + &mut self, + wallet: &Arc>, + multi_wallet: bool, + db: Option<&crate::database::Database>, + ) { let guard = match wallet.read().ok() { Some(g) => g, None => return, @@ -382,72 +395,85 @@ impl AddressInput { // — safer to hide an address than to show it with the wrong type. // Core addresses: only BIP44 paths (m/44'/coin'/account'/change/index). - for (address, derivation_path) in &guard.known_addresses { - if !derivation_path.is_bip44(self.network) { - continue; - } - 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, change_suffix) - } else { - format!("{}{}{}", prefix, truncate_address(&addr_str), change_suffix) - }; - self.all_entries.push(AddressEntry { - address_string: addr_str, - address_kind: AddressKind::Core, - display_label: display, - balance, - validated: ValidatedAddress::Core(address.clone()), - is_change, - }); - } - - // Platform addresses: whitelist only PlatformPayment paths from - // watched_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 addr_info in guard.watched_addresses.values() { - 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()) { + // Read from PlatformWallet's CoreAddressInfo if available. + if let Some(pw) = guard.platform_wallet.as_ref() { + let info = pw.state_blocking(); + for addr_info in crate::platform_wallet_bridge::CoreAddressInfo::all_from_wallet_info(info.managed_state.wallet_info()) { + if !addr_info.derivation_path.is_bip44(self.network) { + continue; + } + let is_change = addr_info.derivation_path.is_bip44_change(self.network); + if self.exclude_change && is_change { continue; } - let balance = guard - .platform_address_info - .get(core_addr) - .map(|info| info.balance) - .unwrap_or(0); + let addr_str = addr_info.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) }; - let bech32m = addr_str.clone(); self.all_entries.push(AddressEntry { address_string: addr_str, - address_kind: AddressKind::Platform, + address_kind: AddressKind::Core, display_label: display, - balance, - validated: ValidatedAddress::Platform { - address: platform_addr, - bech32m, - }, - is_change: false, + balance: addr_info.balance, + validated: ValidatedAddress::Core(addr_info.address), + is_change, }); } } + + // Platform addresses: whitelist only PlatformPayment derivation paths, + // with balance from DB. + // This ensures fresh wallets with no on-chain activity still show + // their derived platform addresses. + let platform_balances: std::collections::HashMap<_, _> = db + .and_then(|d| { + d.get_all_platform_address_info(&guard.seed_hash(), &self.network) + .ok() + }) + .unwrap_or_default() + .into_iter() + .map(|(addr, balance, _nonce)| (addr, balance)) + .collect(); + + let mut seen_platform = std::collections::HashSet::new(); + if let Some(pw) = guard.platform_wallet.as_ref() { + let info = pw.state_blocking(); + for addr_info in crate::platform_wallet_bridge::CoreAddressInfo::all_from_wallet_info(info.managed_state.wallet_info()) { + if !addr_info.derivation_path.is_platform_payment(self.network) { + 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 canonical = Wallet::canonical_address(core_addr, self.network); + let balance = platform_balances.get(&canonical).copied().unwrap_or(0); + 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, + validated: ValidatedAddress::Platform { + address: platform_addr, + bech32m, + }, + is_change: false, + }); + } + } + } + // Locked wallets (no PlatformWallet) show no Platform address entries. } fn extract_identity_entries(&mut self, identities: &[QualifiedIdentity]) { diff --git a/src/ui/dashpay/send_payment.rs b/src/ui/dashpay/send_payment.rs index c3ca3d169..a23e9eb82 100644 --- a/src/ui/dashpay/send_payment.rs +++ b/src/ui/dashpay/send_payment.rs @@ -256,15 +256,16 @@ impl SendPaymentScreen { .strong() .color(DashColors::text_primary(dark_mode)), ); - let balance_dash = if let Some(wallet) = &self.selected_wallet { - if let Ok(wallet_guard) = wallet.read() { - wallet_guard.confirmed_balance_duffs() as f64 / 100_000_000.0 - } else { - 0.0 - } - } else { - 0.0 - }; + let balance_dash = self + .selected_wallet + .as_ref() + .and_then(|w| w.read().ok()) + .and_then(|g| { + g.platform_wallet + .as_ref() + .map(|pw| pw.core().balance().spendable() as f64 / 100_000_000.0) + }) + .unwrap_or(0.0); ui.label( RichText::new(format!("{:.8} DASH", balance_dash)) .color(DashColors::text_primary(dark_mode)), @@ -296,15 +297,16 @@ impl SendPaymentScreen { ui.separator(); // Amount input - use wallet balance for max - let max_balance = if let Some(wallet) = &self.selected_wallet { - if let Ok(wallet_guard) = wallet.read() { - wallet_guard.confirmed_balance_duffs() - } else { - 0 - } - } else { - 0 - }; + let max_balance = self + .selected_wallet + .as_ref() + .and_then(|w| w.read().ok()) + .and_then(|g| { + g.platform_wallet + .as_ref() + .map(|pw| pw.core().balance().spendable()) + }) + .unwrap_or(0); let amount_input = self.amount_input.get_or_insert_with(|| { AmountInput::new(&self.amount) diff --git a/src/ui/dpns/dpns_contested_names_screen.rs b/src/ui/dpns/dpns_contested_names_screen.rs index c4d39b218..015178cae 100644 --- a/src/ui/dpns/dpns_contested_names_screen.rs +++ b/src/ui/dpns/dpns_contested_names_screen.rs @@ -868,7 +868,18 @@ impl DPNSScreen { } } SortColumn::EndingTime => { - let order = a.1.acquired_at.cmp(&b.1.acquired_at); + // Treat 0 (unknown) as maximum so unknowns sort to the end + let key_a = if a.1.acquired_at == 0 { + u64::MAX + } else { + a.1.acquired_at + }; + let key_b = if b.1.acquired_at == 0 { + u64::MAX + } else { + b.1.acquired_at + }; + let order = key_a.cmp(&key_b); if self.sort_order == SortOrder::Descending { order.reverse() } else { @@ -936,12 +947,16 @@ impl DPNSScreen { .color(DashColors::text_primary(dark_mode)), ); }); - let dt = DateTime::from_timestamp( - dpns_info.acquired_at as i64 / 1000, - ((dpns_info.acquired_at % 1000) * 1_000_000) as u32, - ) - .map(|dt| dt.to_string()) - .unwrap_or_else(|| "Invalid timestamp".to_string()); + let dt = if dpns_info.acquired_at == 0 { + "Unknown".to_string() + } else { + DateTime::from_timestamp( + dpns_info.acquired_at as i64 / 1000, + ((dpns_info.acquired_at % 1000) * 1_000_000) as u32, + ) + .map(|dt| dt.to_string()) + .unwrap_or_else(|| "Invalid timestamp".to_string()) + }; row.col(|ui| { let dark_mode = ui.ctx().style().visuals.dark_mode; ui.label( 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 1be8a1735..447ec02b0 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 @@ -18,10 +18,13 @@ impl AddNewIdentityScreen { if let Some(selected_wallet) = &self.selected_wallet { let wallet = selected_wallet.read().unwrap(); - let total_platform_balance: u64 = wallet - .platform_address_info - .values() - .map(|info| info.balance) + let total_platform_balance: u64 = self + .app_context + .db + .get_all_platform_address_info(&wallet.seed_hash(), &self.app_context.network) + .unwrap_or_default() + .iter() + .map(|(_addr, balance, _nonce)| balance) .sum(); let dash_balance = total_platform_balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; @@ -50,27 +53,25 @@ impl AddNewIdentityScreen { self.show_platform_address_balance(ui); ui.add_space(10.0); - // Get Platform addresses from the wallet (using DIP-18 Bech32m format for display) + // Get Platform addresses from the DB (using DIP-18 Bech32m format for display) let network = self.app_context.network; let platform_addresses: Vec<(String, PlatformAddress, u64)> = if let Some(wallet_arc) = &self.selected_wallet { let wallet = wallet_arc.read().unwrap(); - wallet - .platform_addresses(network) + self.app_context + .db + .get_all_platform_address_info(&wallet.seed_hash(), &network) + .unwrap_or_default() .into_iter() - .map(|(core_addr, platform_addr)| { - let balance = wallet - .get_platform_address_info(&core_addr) - .map(|info| info.balance) - .unwrap_or(0); - // Use Bech32m format for display - ( - platform_addr.to_bech32m_string(network), - platform_addr, - balance, - ) + .filter_map(|(core_addr, balance, _nonce)| { + if balance > 0 { + PlatformAddress::try_from(core_addr) + .ok() + .map(|pa| (pa.to_bech32m_string(network), pa, balance)) + } else { + None + } }) - .filter(|(_, _, balance)| *balance > 0) .collect() } else { vec![] 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 e857cbf2e..74d756b56 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 @@ -6,6 +6,11 @@ use crate::ui::identities::add_new_identity_screen::{ AddNewIdentityScreen, FundingMethod, WalletFundedScreenStep, }; use crate::ui::theme::DashColors; +use dash_sdk::dpp::dashcore::transaction::special_transaction::TransactionPayload; +use dash_sdk::dpp::dashcore::{Address, OutPoint}; +use dash_sdk::dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; +use dash_sdk::dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; +use dash_sdk::dpp::prelude::AssetLockProof; use egui::{RichText, Ui}; impl AddNewIdentityScreen { @@ -16,10 +21,18 @@ impl AddNewIdentityScreen { return; }; - // Read the wallet to access unused asset locks + let network = self.app_context.network; + + // Read unused asset locks from the database (identity_id IS NULL). let wallet = selected_wallet.read().unwrap(); + let locks = self + .app_context + .db + .get_unused_asset_lock_transactions_for_wallet(&wallet.seed_hash(), network) + .unwrap_or_default(); + drop(wallet); - if wallet.unused_asset_locks.is_empty() { + if locks.is_empty() { ui.label("No unused asset locks available."); return; } @@ -27,46 +40,73 @@ impl AddNewIdentityScreen { ui.heading("Select an unused asset lock:"); ui.add_space(8.0); - // Track the index of the currently selected asset lock (if any) - let selected_index = self.funding_asset_lock.as_ref().and_then(|(_, proof, _)| { - wallet - .unused_asset_locks - .iter() - .position(|(_, _, _, _, p)| p.as_ref() == Some(proof)) - }); + // Track the txid of the currently selected asset lock (if any) + let selected_txid = self.funding_asset_lock.as_ref().map(|(tx, _, _)| tx.txid()); // Display the asset locks in a scrollable area egui::ScrollArea::vertical() .auto_shrink([false, true]) .min_scrolled_height(180.0) .show(ui, |ui| { - for (index, (tx, address, amount, islock, proof)) in - wallet.unused_asset_locks.iter().enumerate() - { + for (tx, amount, islock, chain_locked_height) in &locks { + let txid = tx.txid(); + ui.group(|ui| { ui.vertical(|ui| { - let tx_id = tx.txid().to_string(); let lock_amount = *amount as f64 * 1e-8; // Convert to DASH - let is_locked = if islock.is_some() { "Yes" } else { "No" }; + let is_locked = islock.is_some() || chain_locked_height.is_some(); + + let address_str = if let Some(TransactionPayload::AssetLockPayloadType(payload)) = &tx.special_transaction_payload { + payload.credit_outputs.first() + .and_then(|output| Address::from_script(&output.script_pubkey, network).ok()) + .map(|a| a.to_string()) + .unwrap_or_else(|| "Unknown".to_string()) + } else { + "Unknown".to_string() + }; // Display asset lock information with "Selected" if this one is selected - if Some(index) == selected_index { + if selected_txid == Some(txid) { ui.colored_label(DashColors::SUCCESS, "Selected asset lock"); } - ui.label(format!("TxID: {}", tx_id)); - ui.label(format!("Address: {}", address)); + ui.label(format!("TxID: {}", txid)); + ui.label(format!("Address: {}", address_str)); ui.label(format!("Amount: {:.8} DASH", lock_amount)); - ui.label(format!("InstantLock: {}", is_locked)); + ui.label(format!("InstantLock: {}", if is_locked { "Yes" } else { "No" })); ui.add_space(6.0); + // Build proof from IS-lock or chain-locked height + let proof = if let Some(islock) = islock { + Some(AssetLockProof::Instant(InstantAssetLockProof::new( + islock.clone(), + tx.clone(), + 0, + ))) + } else { + chain_locked_height.map(|height| { + AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height: height, + out_point: OutPoint::new(txid, 0), + }) + }) + }; + if let Some(asset_lock_proof) = proof { if ui.button("Select").clicked() { + let address = if let Some(TransactionPayload::AssetLockPayloadType(payload)) = &tx.special_transaction_payload { + payload.credit_outputs.first() + .and_then(|output| Address::from_script(&output.script_pubkey, network).ok()) + .unwrap_or_else(|| Address::from_script(&tx.output[0].script_pubkey, network).unwrap()) + } else { + Address::from_script(&tx.output[0].script_pubkey, network).unwrap() + }; + self.funding_asset_lock = Some(( tx.clone(), - asset_lock_proof.clone(), - address.clone(), + asset_lock_proof, + address, )); let mut step = self.step.write().unwrap(); 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 45abb21d9..442e836b7 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 @@ -9,11 +9,17 @@ use egui::{Color32, RichText, Ui}; impl AddNewIdentityScreen { fn show_wallet_balance(&self, ui: &mut egui::Ui) { if let Some(selected_wallet) = &self.selected_wallet { - let wallet = selected_wallet.read().unwrap(); // Read lock on the wallet - - let total_balance: u64 = wallet.total_balance_duffs(); // Use stored balance with UTXO fallback - - let dash_balance = total_balance as f64 * 1e-8; // Convert to DASH units + let total_balance = selected_wallet + .read() + .ok() + .and_then(|g| { + g.platform_wallet + .as_ref() + .map(|pw| pw.core().balance().total()) + }) + .unwrap_or(0); + + let dash_balance = total_balance as f64 * 1e-8; ui.horizontal(|ui| { ui.label(format!("Wallet Balance: {:.8} DASH", dash_balance)); 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 d8c173e59..3a9e88f37 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 @@ -161,16 +161,17 @@ impl AddNewIdentityScreen { let Some(selected_wallet) = &self.selected_wallet else { return AppAction::None; }; - if let Some((utxo, tx_out, address)) = self.funding_utxo.clone() { + if let Some((_utxo, tx_out, _address)) = self.funding_utxo.clone() { + // Note: the specific QR-funded UTXO is not forced — the wallet's + // coin selection will pick UTXOs automatically. The amount matches + // what was received via QR. let identity_input = IdentityRegistrationInfo { alias_input: self.alias_input.clone(), keys: self.identity_keys.clone(), wallet: Arc::clone(selected_wallet), // Clone the Arc reference wallet_identity_index: self.identity_id_number, - identity_funding_method: RegisterIdentityFundingMethod::FundWithUtxo( - utxo, - tx_out, - address, + identity_funding_method: RegisterIdentityFundingMethod::FundWithWallet( + tx_out.value, self.identity_id_number, ), }; diff --git a/src/ui/identities/add_new_identity_screen/mod.rs b/src/ui/identities/add_new_identity_screen/mod.rs index edcf46404..13eb8ba27 100644 --- a/src/ui/identities/add_new_identity_screen/mod.rs +++ b/src/ui/identities/add_new_identity_screen/mod.rs @@ -35,7 +35,6 @@ use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; use dash_sdk::dpp::prelude::AssetLockProof; use dash_sdk::platform::Identifier; use eframe::egui::Context; -use egui::ahash::HashSet; use egui::{Align, Button, Color32, ComboBox, ScrollArea, Ui}; use egui_extras::{Column, TableBuilder}; @@ -265,7 +264,10 @@ impl AddNewIdentityScreen { // Check if we have access to the selected wallet if let Some(wallet_guard) = self.selected_wallet.as_ref() { let wallet = wallet_guard.read().unwrap(); - let used_indices: HashSet = wallet.identities.keys().cloned().collect(); + let used_indices: std::collections::HashSet = self + .app_context + .db + .get_wallet_identity_indices(&wallet.seed_hash(), self.app_context.network); // Modify the selected text to include "(used)" if the current index is used let selected_text = { @@ -406,18 +408,13 @@ impl AddNewIdentityScreen { } /// Generate next identity ID that can be used for the new identity. - /// - /// TODO: This function is not working in a reliable way, because it relies on the - /// `identities` map in the wallet, which may not be up to date (user can remove - /// identities from the wallet while they still are stored on the Platform). fn next_identity_id(&self) -> u32 { - self.selected_wallet - .as_ref() - .unwrap() - .read() - .unwrap() - .identities - .keys() + let wallet = self.selected_wallet.as_ref().unwrap().read().unwrap(); + let used = self + .app_context + .db + .get_wallet_identity_indices(&wallet.seed_hash(), self.app_context.network); + used.iter() .copied() .max() .map(|max| max + 1) @@ -451,7 +448,11 @@ impl AddNewIdentityScreen { let (has_unused_asset_lock, has_balance) = { let wallet = selected_wallet.read().unwrap(); - (wallet.has_unused_asset_lock(), wallet.has_balance()) + ( + wallet + .has_unused_asset_lock(&self.app_context.db, self.app_context.network), + wallet.has_balance(), + ) }; if has_unused_asset_lock @@ -501,10 +502,15 @@ impl AddNewIdentityScreen { // Check if wallet has Platform address balance let has_platform_balance = { let wallet = selected_wallet.read().unwrap(); - wallet - .platform_address_info - .values() - .any(|info| info.balance > 0) + self.app_context + .db + .get_all_platform_address_info( + &wallet.seed_hash(), + &self.app_context.network, + ) + .unwrap_or_default() + .iter() + .any(|(_addr, balance, _nonce)| *balance > 0) }; if has_platform_balance && ui @@ -811,16 +817,15 @@ impl AddNewIdentityScreen { }; match funding_method { FundingMethod::UseUnusedAssetLock => { - if let Some((tx, funding_asset_lock, address)) = self.funding_asset_lock.clone() { + if let Some((tx, _funding_asset_lock, _address)) = self.funding_asset_lock.clone() { + let out_point = OutPoint::new(tx.txid(), 0); let identity_input = IdentityRegistrationInfo { alias_input: self.alias_input.clone(), keys: self.identity_keys.clone(), wallet: Arc::clone(selected_wallet), // Clone the Arc reference wallet_identity_index: self.identity_id_number, identity_funding_method: RegisterIdentityFundingMethod::UseAssetLock( - address, - Box::new(funding_asset_lock), - Box::new(tx), + out_point, ), }; @@ -921,11 +926,14 @@ impl AddNewIdentityScreen { // Calculate max amount if using wallet balance let max_amount_credits = if funding_method == FundingMethod::UseWalletBalance { - self.selected_wallet.as_ref().map(|wallet| { - let wallet = wallet.read().unwrap(); - // Convert duffs to credits (1 duff = 1000 credits) - wallet.total_balance_duffs() * 1000 - }) + self.selected_wallet + .as_ref() + .and_then(|w| w.read().ok()) + .and_then(|g| { + g.platform_wallet + .as_ref() + .map(|pw| pw.core().balance().total() * 1000) + }) } else { None }; @@ -1084,7 +1092,7 @@ impl ScreenLike for AddNewIdentityScreen { }; if let Some(wallet) = &self.selected_wallet { let wallet = wallet.read().unwrap(); - wallet.known_addresses.contains_key(&address) + wallet.has_address(&address) } else { false } @@ -1179,9 +1187,8 @@ impl ScreenLike for AddNewIdentityScreen { // Display the heading with an info icon that shows a tooltip on hover ui.horizontal(|ui| { - let wallet_guard = self.selected_wallet.as_ref().unwrap(); - let wallet = wallet_guard.read().unwrap(); - if wallet.identities.is_empty() { + let next_id = self.next_identity_id(); + if next_id == 0 { ui.heading(format!( "{}. Choose an identity index for the wallet. Leaving this 0 is recommended.", step_number @@ -1190,7 +1197,7 @@ impl ScreenLike for AddNewIdentityScreen { ui.heading(format!( "{}. Choose an identity index for the wallet. Leaving this {} is recommended.", step_number, - self.next_identity_id(), + next_id, )); } diff --git a/src/ui/identities/funding_common.rs b/src/ui/identities/funding_common.rs index 98c35555e..173c452b2 100644 --- a/src/ui/identities/funding_common.rs +++ b/src/ui/identities/funding_common.rs @@ -66,21 +66,18 @@ pub fn capture_qr_funding_utxo_if_available( } let address = funding_address.cloned()?; - let wallet_arc = wallet?; - let candidate_utxo = { - let wallet = wallet_arc - .read() - .expect("wallet lock poisoned while checking funding UTXO"); - wallet.utxos.get(&address).and_then(|utxos| { - utxos - .iter() - .filter(|(_, tx_out)| tx_out.value > 0) - .max_by_key(|(_, tx_out)| tx_out.value) - .map(|(outpoint, tx_out)| (*outpoint, tx_out.clone())) - }) - }; + let guard = wallet_arc.read().ok()?; + let pw = guard.platform_wallet.as_ref()?; + let info = pw.state_blocking(); + + let candidate_utxo = info + .managed_state.wallet_info().get_spendable_utxos() + .iter() + .filter(|utxo| utxo.address == address && utxo.value() > 0) + .max_by_key(|utxo| utxo.value()) + .map(|utxo| (utxo.outpoint, utxo.txout.clone())); if let Some((outpoint, tx_out)) = candidate_utxo { let mut step = step diff --git a/src/ui/identities/top_up_identity_screen/by_platform_address.rs b/src/ui/identities/top_up_identity_screen/by_platform_address.rs index 59d2f21fb..120defcb9 100644 --- a/src/ui/identities/top_up_identity_screen/by_platform_address.rs +++ b/src/ui/identities/top_up_identity_screen/by_platform_address.rs @@ -219,18 +219,20 @@ impl TopUpIdentityScreen { return vec![]; }; - let network = self.app_context.network; - wallet - .platform_addresses(network) + self.app_context + .db + .get_all_platform_address_info(&wallet.seed_hash(), &self.app_context.network) + .unwrap_or_default() .into_iter() - .map(|(core_addr, platform_addr)| { - let balance = wallet - .get_platform_address_info(&core_addr) - .map(|info| info.balance) - .unwrap_or(0); - (core_addr, platform_addr, balance) + .filter_map(|(core_addr, balance, _nonce)| { + if balance > 0 { + PlatformAddress::try_from(core_addr.clone()) + .ok() + .map(|pa| (core_addr, pa, balance)) + } else { + None + } }) - .filter(|(_, _, balance)| *balance > 0) .collect() } 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 dfca0e870..af1d06b79 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 @@ -5,6 +5,11 @@ 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; +use dash_sdk::dpp::dashcore::transaction::special_transaction::TransactionPayload; +use dash_sdk::dpp::dashcore::{Address, OutPoint}; +use dash_sdk::dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; +use dash_sdk::dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; +use dash_sdk::dpp::prelude::AssetLockProof; use egui::{Color32, Frame, Margin, RichText, Ui}; impl TopUpIdentityScreen { @@ -15,36 +20,47 @@ impl TopUpIdentityScreen { return; }; - // Read the wallet to access unused asset locks - let wallet = selected_wallet.read().unwrap(); + let network = self.app_context.network; - if wallet.unused_asset_locks.is_empty() { + // Read unused asset locks from the database (identity_id IS NULL). + let wallet = selected_wallet.read().unwrap(); + let locks = self + .app_context + .db + .get_unused_asset_lock_transactions_for_wallet(&wallet.seed_hash(), network) + .unwrap_or_default(); + drop(wallet); + + if locks.is_empty() { ui.label("No unused asset locks available."); return; } ui.heading("Select an unused asset lock:"); - // Track the index of the currently selected asset lock (if any) - let selected_index = self.funding_asset_lock.as_ref().and_then(|(_, proof, _)| { - wallet - .unused_asset_locks - .iter() - .position(|(_, _, _, _, p)| p.as_ref() == Some(proof)) - }); + // Track the txid of the currently selected asset lock (if any) + let selected_txid = self.funding_asset_lock.as_ref().map(|(tx, _, _)| tx.txid()); // Display the asset locks in a scrollable area egui::ScrollArea::vertical().show(ui, |ui| { - for (index, (tx, address, amount, islock, proof)) in - wallet.unused_asset_locks.iter().enumerate() - { + for (tx, amount, islock, chain_locked_height) in &locks { + let txid = tx.txid(); + ui.horizontal(|ui| { - let tx_id = tx.txid().to_string(); let lock_amount = *amount as f64 * 1e-8; // Convert to DASH - let is_locked = if islock.is_some() { "Yes" } else { "No" }; + let is_locked = islock.is_some() || chain_locked_height.is_some(); + + let address_str = if let Some(TransactionPayload::AssetLockPayloadType(payload)) = &tx.special_transaction_payload { + payload.credit_outputs.first() + .and_then(|output| Address::from_script(&output.script_pubkey, network).ok()) + .map(|a| a.to_string()) + .unwrap_or_else(|| "Unknown".to_string()) + } else { + "Unknown".to_string() + }; // Display asset lock information with "Selected" if this one is selected - let selected_text = if Some(index) == selected_index { + let selected_text = if selected_txid == Some(txid) { " (Selected)" } else { "" @@ -52,13 +68,37 @@ impl TopUpIdentityScreen { ui.label(format!( "TxID: {}, Address: {}, Amount: {:.8} DASH, InstantLock: {}{}", - tx_id, address, lock_amount, is_locked, selected_text + txid, address_str, lock_amount, if is_locked { "Yes" } else { "No" }, selected_text )); + // Build proof from IS-lock or chain-locked height + let proof = if let Some(islock) = islock { + Some(AssetLockProof::Instant(InstantAssetLockProof::new( + islock.clone(), + tx.clone(), + 0, + ))) + } else { + chain_locked_height.map(|height| { + AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height: height, + out_point: OutPoint::new(txid, 0), + }) + }) + }; + if let Some(asset_lock_proof) = proof { if ui.button("Select").clicked() { + let address = if let Some(TransactionPayload::AssetLockPayloadType(payload)) = &tx.special_transaction_payload { + payload.credit_outputs.first() + .and_then(|output| Address::from_script(&output.script_pubkey, network).ok()) + .unwrap_or_else(|| Address::from_script(&tx.output[0].script_pubkey, network).unwrap()) + } else { + Address::from_script(&tx.output[0].script_pubkey, network).unwrap() + }; + self.funding_asset_lock = - Some((tx.clone(), asset_lock_proof.clone(), address.clone())); + Some((tx.clone(), asset_lock_proof, address)); let mut step = self.step.write().unwrap(); *step = WalletFundedScreenStep::ReadyToCreate; diff --git a/src/ui/identities/top_up_identity_screen/by_using_unused_balance.rs b/src/ui/identities/top_up_identity_screen/by_using_unused_balance.rs index 5f1227baa..a79b171ef 100644 --- a/src/ui/identities/top_up_identity_screen/by_using_unused_balance.rs +++ b/src/ui/identities/top_up_identity_screen/by_using_unused_balance.rs @@ -8,11 +8,17 @@ use egui::{Color32, Frame, Margin, RichText, Ui}; impl TopUpIdentityScreen { fn show_wallet_balance(&self, ui: &mut egui::Ui) { if let Some(selected_wallet) = &self.wallet { - let wallet = selected_wallet.read().unwrap(); // Read lock on the wallet - - let total_balance: u64 = wallet.total_balance_duffs(); // Use stored balance with UTXO fallback - - let dash_balance = total_balance as f64 * 1e-8; // Convert to DASH units + let total_balance = selected_wallet + .read() + .ok() + .and_then(|g| { + g.platform_wallet + .as_ref() + .map(|pw| pw.core().balance().total()) + }) + .unwrap_or(0); + + let dash_balance = total_balance as f64 * 1e-8; ui.horizontal(|ui| { ui.label(format!("Wallet Balance: {:.8} DASH", dash_balance)); diff --git a/src/ui/identities/top_up_identity_screen/by_wallet_qr_code.rs b/src/ui/identities/top_up_identity_screen/by_wallet_qr_code.rs index 3e4264a2a..bd9b036f5 100644 --- a/src/ui/identities/top_up_identity_screen/by_wallet_qr_code.rs +++ b/src/ui/identities/top_up_identity_screen/by_wallet_qr_code.rs @@ -132,7 +132,7 @@ impl TopUpIdentityScreen { let Some(selected_wallet) = &self.wallet else { return AppAction::None; }; - if let Some((utxo, tx_out, address)) = self.funding_utxo.clone() { + if let Some((_utxo, tx_out, _address)) = self.funding_utxo.clone() { let wallet_index = self.identity.wallet_index.unwrap_or(u32::MAX >> 1); let top_up_index = self .identity @@ -142,13 +142,14 @@ impl TopUpIdentityScreen { .cloned() .map(|i| i + 1) .unwrap_or_default(); + // Note: the specific QR-funded UTXO is not forced — the wallet's + // coin selection will pick UTXOs automatically. The amount matches + // what was received via QR. let identity_input = IdentityTopUpInfo { qualified_identity: self.identity.clone(), wallet: Arc::clone(selected_wallet), - identity_funding_method: TopUpIdentityFundingMethod::FundWithUtxo( - utxo, - tx_out, - address, + identity_funding_method: TopUpIdentityFundingMethod::FundWithWallet( + tx_out.value, wallet_index, top_up_index, ), diff --git a/src/ui/identities/top_up_identity_screen/mod.rs b/src/ui/identities/top_up_identity_screen/mod.rs index 4f00be289..ec3e83a54 100644 --- a/src/ui/identities/top_up_identity_screen/mod.rs +++ b/src/ui/identities/top_up_identity_screen/mod.rs @@ -124,9 +124,11 @@ impl TopUpIdentityScreen { let has_resources = match funding_method { FundingMethod::UseWalletBalance => wallet_read.has_balance(), - FundingMethod::UseUnusedAssetLock => { - wallet_read.has_unused_asset_lock() - } + FundingMethod::UseUnusedAssetLock => wallet_read + .has_unused_asset_lock( + &self.app_context.db, + self.app_context.network, + ), _ => true, }; @@ -157,9 +159,10 @@ impl TopUpIdentityScreen { let wallet_read = wallet.read().unwrap(); match funding_method { FundingMethod::UseWalletBalance => wallet_read.has_balance(), - FundingMethod::UseUnusedAssetLock => { - wallet_read.has_unused_asset_lock() - } + FundingMethod::UseUnusedAssetLock => wallet_read.has_unused_asset_lock( + &self.app_context.db, + self.app_context.network, + ), _ => true, } }; @@ -223,13 +226,21 @@ impl TopUpIdentityScreen { for wallet in wallets.values() { let wallet = wallet.read().unwrap(); - if wallet.has_unused_asset_lock() { + if wallet.has_unused_asset_lock(&self.app_context.db, self.app_context.network) { has_unused_asset_lock = true; } if wallet.has_balance() { has_balance = true; } - if wallet.total_platform_balance() > 0 { + let total_platform_balance: u64 = self + .app_context + .db + .get_all_platform_address_info(&wallet.seed_hash(), &self.app_context.network) + .unwrap_or_default() + .iter() + .map(|(_addr, balance, _nonce)| balance) + .sum(); + if total_platform_balance > 0 { has_platform_balance = true; } if has_unused_asset_lock && has_balance && has_platform_balance { @@ -312,14 +323,13 @@ impl TopUpIdentityScreen { }; match funding_method { FundingMethod::UseUnusedAssetLock => { - if let Some((tx, funding_asset_lock, address)) = self.funding_asset_lock.clone() { + if let Some((tx, _funding_asset_lock, _address)) = self.funding_asset_lock.clone() { + let out_point = OutPoint::new(tx.txid(), 0); let identity_input = IdentityTopUpInfo { qualified_identity: self.identity.clone(), wallet: Arc::clone(selected_wallet), identity_funding_method: TopUpIdentityFundingMethod::UseAssetLock( - address, - Box::new(funding_asset_lock), - Box::new(tx), + out_point, ), }; @@ -380,7 +390,12 @@ impl TopUpIdentityScreen { let max_amount_duffs = self .wallet .as_ref() - .map(|w| w.read().unwrap().total_balance_duffs()) + .and_then(|w| w.read().ok()) + .and_then(|g| { + g.platform_wallet + .as_ref() + .map(|pw| pw.core().balance().total()) + }) .unwrap_or(0); // Convert Duffs to Credits (1 Duff = 1000 Credits) let total_credits = max_amount_duffs * 1000; @@ -494,7 +509,7 @@ impl ScreenLike for TopUpIdentityScreen { }; if let Some(wallet) = &self.wallet { let wallet = wallet.read().unwrap(); - wallet.known_addresses.contains_key(&address) + wallet.has_address(&address) } else { false } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index e460c6c97..a3a70105e 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -303,7 +303,7 @@ pub enum ScreenType { SetTokenPriceScreen(IdentityTokenInfo), // Wallet screens - AssetLockDetail([u8; 32], usize), + AssetLockDetail([u8; 32], [u8; 32]), CreateAssetLock(Arc>), // Shielded screens @@ -628,8 +628,8 @@ impl ScreenType { ScreenType::SetTokenPriceScreen(identity_token_info) => Screen::SetTokenPriceScreen( SetTokenPriceScreen::new(identity_token_info.clone(), app_context), ), - ScreenType::AssetLockDetail(wallet_seed_hash, index) => Screen::AssetLockDetailScreen( - AssetLockDetailScreen::new(*wallet_seed_hash, *index, app_context), + ScreenType::AssetLockDetail(wallet_seed_hash, txid) => Screen::AssetLockDetailScreen( + AssetLockDetailScreen::new(*wallet_seed_hash, *txid, app_context), ), ScreenType::CreateAssetLock(wallet) => Screen::CreateAssetLockScreen( CreateAssetLockScreen::new(wallet.clone(), app_context), @@ -1067,7 +1067,7 @@ impl Screen { ScreenType::SetTokenPriceScreen(screen.identity_token_info.clone()) } Screen::AssetLockDetailScreen(screen) => { - ScreenType::AssetLockDetail(screen.wallet_seed_hash, screen.asset_lock_index) + ScreenType::AssetLockDetail(screen.wallet_seed_hash, screen.asset_lock_txid) } Screen::CreateAssetLockScreen(screen) => { ScreenType::CreateAssetLock(screen.wallet.clone()) diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index a8a6cc7dc..cd46a330c 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -5,7 +5,6 @@ use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::config::Config; use crate::context::AppContext; use crate::context::connection_status::{ConnectionStatus, OverallConnectionState}; -use crate::model::wallet::DerivationPathHelpers; use crate::spv::{CoreBackendMode, SpvStatus, SpvStatusSnapshot}; use crate::ui::components::MessageBanner; use crate::ui::components::component_trait::Component; @@ -355,7 +354,7 @@ impl NetworkChooserScreen { // Check if currently connected via SPV (only SPV restricts network switching) let is_spv_connected = if current_backend_mode == CoreBackendMode::Spv { let ctx = self.current_app_context(); - let snapshot = ctx.spv_manager().status(); + let snapshot = ctx.spv_event_bridge().status(); snapshot.status.is_active() } else { false // Core mode doesn't restrict network switching @@ -604,7 +603,7 @@ impl NetworkChooserScreen { 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()) + Some(ctx.spv_event_bridge().status().clone()) } else { None }; @@ -1250,37 +1249,11 @@ impl NetworkChooserScreen { ); // Also clear from in-memory wallets if let Ok(wallets) = current_context.wallets.read() { - for wallet_arc in wallets.values() { - if let Ok(mut wallet) = wallet_arc.write() { - // Clear platform address info - wallet.platform_address_info.clear(); - - // Remove platform addresses from known_addresses - wallet.known_addresses.retain(|_, path| { - !path.is_platform_payment(current_context.network) - }); - - // Remove platform addresses from watched_addresses - wallet.watched_addresses.retain(|path, _| { - !path.is_platform_payment(current_context.network) - }); - - // Remove platform addresses from address_balances - let platform_addrs: Vec<_> = wallet - .address_balances - .keys() - .filter(|addr| { - // Check if this address was a platform address - // by seeing if it's not in known_addresses anymore - !wallet.known_addresses.contains_key(*addr) - }) - .cloned() - .collect(); - for addr in platform_addrs { - wallet.address_balances.remove(&addr); - } - } - } + // Platform address info is stored in the DB + // (cleared above via clear_all_platform_addresses). + // Platform addresses managed by PlatformWallet -- + // no manual cleanup needed on network switch. + drop(wallets); } } Err(e) => { @@ -1370,19 +1343,10 @@ impl NetworkChooserScreen { .db .update_use_local_spv_node(self.use_local_spv_node); - // Update all network contexts - self.mainnet_app_context - .spv_manager() - .set_use_local_node(self.use_local_spv_node); - if let Some(ref ctx) = self.testnet_app_context { - ctx.spv_manager().set_use_local_node(self.use_local_spv_node); - } - if let Some(ref ctx) = self.devnet_app_context { - ctx.spv_manager().set_use_local_node(self.use_local_spv_node); - } - if let Some(ref ctx) = self.local_app_context { - ctx.spv_manager().set_use_local_node(self.use_local_spv_node); - } + // Preference is persisted in the DB above. + // It is read by build_spv_config() at SPV start time. + // No runtime state update needed — a restart of SPV + // will pick up the new value. } ui.label( egui::RichText::new(if self.use_local_spv_node { @@ -1521,7 +1485,7 @@ impl NetworkChooserScreen { if self.developer_mode { let current_backend_mode = self.current_app_context().core_backend_mode(); if current_backend_mode == CoreBackendMode::Spv { - let snapshot = self.current_app_context().spv_manager().status(); + let snapshot = self.current_app_context().spv_event_bridge().status(); ui.add_space(12.0); ui.separator(); ui.add_space(12.0); diff --git a/src/ui/tokens/tokens_screen/mod.rs b/src/ui/tokens/tokens_screen/mod.rs index ca1d46f82..24dc9ad40 100644 --- a/src/ui/tokens/tokens_screen/mod.rs +++ b/src/ui/tokens/tokens_screen/mod.rs @@ -4,6 +4,39 @@ mod distributions; mod groups; mod keyword_search; mod my_tokens; + +fn my_tokens( + app_context: &Arc, + identities: &IndexMap, + all_known_tokens: &IndexMap, + token_pricing_data: &IndexMap< + Identifier, + Option, + >, +) -> IndexMap { + let in_dev_mode = app_context.is_developer_mode(); + + app_context + .identity_token_balances() + .unwrap_or_default() + .into_iter() + .filter_map(|(id_token_identifier, token_balance)| { + // Lookup identity + let identity = identities.get(&token_balance.identity_id)?; + // Lookup contract + let contract = all_known_tokens + .get(&token_balance.token_id) + .map(|info| &info.data_contract)?; + + let token_pricing = token_pricing_data + .get(&token_balance.token_id) + .and_then(|opt| opt.as_ref()); + let token_with_actions = + token_balance.into_with_actions(identity, contract, in_dev_mode, token_pricing); + Some((id_token_identifier, token_with_actions)) + }) + .collect() +} mod structs; mod token_creator; @@ -1473,39 +1506,6 @@ impl IntervalTimeUnit { } } -fn my_tokens( - app_context: &Arc, - identities: &IndexMap, - all_known_tokens: &IndexMap, - token_pricing_data: &IndexMap< - Identifier, - Option, - >, -) -> IndexMap { - let in_dev_mode = app_context.is_developer_mode(); - - app_context - .identity_token_balances() - .unwrap_or_default() - .into_iter() - .filter_map(|(id_token_identifier, token_balance)| { - // Lookup identity - let identity = identities.get(&token_balance.identity_id)?; - // Lookup contract - let contract = all_known_tokens - .get(&token_balance.token_id) - .map(|info| &info.data_contract)?; - - let token_pricing = token_pricing_data - .get(&token_balance.token_id) - .and_then(|opt| opt.as_ref()); - let token_with_actions = - token_balance.into_with_actions(identity, contract, in_dev_mode, token_pricing); - Some((id_token_identifier, token_with_actions)) - }) - .collect() -} - impl TokensScreen { pub fn new(app_context: &Arc, tokens_subscreen: TokensSubscreen) -> Self { let identities = app_context diff --git a/src/ui/wallets/account_summary.rs b/src/ui/wallets/account_summary.rs index 9f1e7edc8..a1f3f9a7f 100644 --- a/src/ui/wallets/account_summary.rs +++ b/src/ui/wallets/account_summary.rs @@ -184,6 +184,10 @@ pub(crate) fn categorize_account_path( AccountCategory::Bip32 } else if path.is_bip44(network) { AccountCategory::Bip44 + } else if path.is_platform_payment(network) { + AccountCategory::PlatformPayment + } else if path.is_asset_lock_funding(network) { + AccountCategory::IdentityRegistration } else { AccountCategory::from_reference(reference) }; @@ -241,32 +245,47 @@ impl AccountSummaryBuilder { } } -pub fn collect_account_summaries(wallet: &Wallet, network: Network) -> Vec { +pub fn collect_account_summaries( + wallet: &Wallet, + network: Network, + db: &crate::database::Database, +) -> Vec { let mut builders: BTreeMap = BTreeMap::new(); - for (path, info) in &wallet.watched_addresses { - let (category, index) = categorize_account_path(path, network, info.path_reference); - - let balance = wallet - .address_balances - .get(&info.address) - .cloned() - .unwrap_or_default(); - - // Get Platform credits balance for Platform Payment addresses - // Use canonical lookup to handle potential Address key mismatches - let platform_credits = wallet - .get_platform_address_info(&info.address) - .map(|info| info.balance) - .unwrap_or_default(); - - builders - .entry(AccountKey { - category: category.clone(), - index, - }) - .or_insert_with(|| AccountSummaryBuilder::new(category, index)) - .add_address(balance, platform_credits); + // Build a lookup map of platform address balances from DB + let platform_balances: std::collections::HashMap<_, _> = db + .get_all_platform_address_info(&wallet.seed_hash(), &network) + .unwrap_or_default() + .into_iter() + .map(|(addr, balance, _nonce)| (addr, balance)) + .collect(); + + // Use PlatformWallet's CoreAddressInfo as the canonical source. + // Locked wallets (no PlatformWallet) show no account summaries. + if let Some(pw) = wallet.platform_wallet.as_ref() { + let info = pw.state_blocking(); + for addr_info in crate::platform_wallet_bridge::CoreAddressInfo::all_from_wallet_info(info.managed_state.wallet_info()) { + let (category, index) = categorize_account_path( + &addr_info.derivation_path, + network, + DerivationPathReference::Unknown, + ); + + // Get Platform credits balance for Platform Payment addresses from DB + let canonical = Wallet::canonical_address(&addr_info.address, network); + let platform_credits = platform_balances + .get(&canonical) + .copied() + .unwrap_or_default(); + + builders + .entry(AccountKey { + category: category.clone(), + index, + }) + .or_insert_with(|| AccountSummaryBuilder::new(category, index)) + .add_address(addr_info.balance, platform_credits); + } } let mut summaries: Vec<_> = builders diff --git a/src/ui/wallets/add_new_wallet_screen.rs b/src/ui/wallets/add_new_wallet_screen.rs index 74ebdc34b..372a21494 100644 --- a/src/ui/wallets/add_new_wallet_screen.rs +++ b/src/ui/wallets/add_new_wallet_screen.rs @@ -172,22 +172,33 @@ impl AddNewWalletScreen { .as_ref() .and_then(|ws| ws.get(self.selected_core_wallet_index).cloned()); - // Extract first receive address for display before registering - if let Some((address, _)) = wallet.known_addresses.first_key_value() { - self.receive_address_string = Some(address.to_string()); - self.receive_address = Some(address.clone()); - } - - let (new_wallet_seed_hash, _wallet_arc) = self + let (new_wallet_seed_hash, wallet_arc) = self .app_context .register_wallet(wallet) .map_err(|e| e.to_string())?; + // Extract first receive address from PlatformWallet (created during register) + if let Ok(guard) = wallet_arc.read() { + if let Some(addr_info) = guard.all_addresses_info().into_iter().next() { + self.receive_address_string = Some(addr_info.address.to_string()); + self.receive_address = Some(addr_info.address); + } + } + // Set pending wallet selection so the wallet screen auto-selects this wallet if let Ok(mut pending) = self.app_context.pending_wallet_selection.lock() { *pending = Some(new_wallet_seed_hash); } + // Register with PlatformWallet bridge (always, regardless of backend mode). + // Read seed bytes from the already-stored wallet. + if let Ok(guard) = wallet_arc.read() + && let Ok(seed_bytes) = guard.seed_bytes() + { + self.app_context + .register_with_platform_wallet_manager(new_wallet_seed_hash, *seed_bytes); + } + self.created_wallet_seed_hash = Some(new_wallet_seed_hash); self.wallet_created = true; Ok(AppAction::None) // Show success screen instead of navigating away @@ -200,14 +211,14 @@ impl AddNewWalletScreen { let mut action = AppAction::None; let dark_mode = ui.ctx().style().visuals.dark_mode; - // Check for incoming funds by looking at wallet balance - // Use total_balance_duffs() which falls back to max_balance() (from UTXOs) if SPV balance not set + // Check for incoming funds by looking at wallet balance (lock-free via WalletBalance) if !self.funds_received { if let Some(seed_hash) = &self.created_wallet_seed_hash && let Ok(wallets) = self.app_context.wallets.read() && let Some(wallet) = wallets.get(seed_hash) - && let Ok(wallet_guard) = wallet.read() - && wallet_guard.total_balance_duffs() > 0 + && let Ok(guard) = wallet.read() + && let Some(pw) = guard.platform_wallet.as_ref() + && pw.core().balance().total() > 0 { self.funds_received = true; // Auto-close the popup when funds are received diff --git a/src/ui/wallets/asset_lock_detail_screen.rs b/src/ui/wallets/asset_lock_detail_screen.rs index e39d4f7e0..ea8bebf52 100644 --- a/src/ui/wallets/asset_lock_detail_screen.rs +++ b/src/ui/wallets/asset_lock_detail_screen.rs @@ -10,8 +10,10 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; use crate::ui::theme::{ComponentStyles, DashColors}; use crate::ui::{MessageType, RootScreenType, ScreenLike}; -use dash_sdk::dashcore_rpc::dashcore::{Address, InstantLock, Transaction}; -use dash_sdk::dpp::fee::Credits; +use dash_sdk::dpp::dashcore::transaction::special_transaction::TransactionPayload; +use dash_sdk::dpp::dashcore::{Address, InstantLock, OutPoint, Transaction}; +use dash_sdk::dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; +use dash_sdk::dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; use dash_sdk::dpp::prelude::AssetLockProof; use eframe::egui::{self, Context, Ui}; use egui::{Color32, Frame, Margin, RichText}; @@ -19,7 +21,7 @@ use std::sync::{Arc, RwLock}; pub struct AssetLockDetailScreen { pub wallet_seed_hash: [u8; 32], - pub asset_lock_index: usize, + pub asset_lock_txid: [u8; 32], pub app_context: Arc, wallet: Option>>, password_input: PasswordInput, @@ -30,7 +32,7 @@ pub struct AssetLockDetailScreen { impl AssetLockDetailScreen { pub fn new( wallet_seed_hash: [u8; 32], - asset_lock_index: usize, + asset_lock_txid: [u8; 32], app_context: &Arc, ) -> Self { // Find the wallet by seed hash @@ -44,7 +46,7 @@ impl AssetLockDetailScreen { Self { wallet_seed_hash, - asset_lock_index, + asset_lock_txid, app_context: app_context.clone(), wallet, password_input: PasswordInput::new().with_hint_text("Enter password"), @@ -53,29 +55,60 @@ impl AssetLockDetailScreen { } } - #[allow(clippy::type_complexity)] - fn get_asset_lock_data( - &self, - ) -> Option<( - Transaction, - Address, - Credits, - Option, - Option, - )> { - self.wallet.as_ref().and_then(|wallet| { - let wallet = wallet.read().unwrap(); - wallet - .unused_asset_locks - .get(self.asset_lock_index) - .cloned() - }) + /// Get the asset lock data from the database by txid. + fn get_asset_lock_data(&self) -> Option<(Transaction, Address, u64, Option)> { + let (tx, amount, islock, chain_locked_height, _identity_id, _wallet_seed, _network) = self + .app_context + .db + .get_asset_lock_transaction(&self.asset_lock_txid) + .ok()??; + + let network = self.app_context.network; + let address = if let Some(TransactionPayload::AssetLockPayloadType(payload)) = + &tx.special_transaction_payload + { + payload + .credit_outputs + .first() + .and_then(|output| Address::from_script(&output.script_pubkey, network).ok()) + .unwrap_or_else(|| { + Address::from_script(&tx.output[0].script_pubkey, network).unwrap() + }) + } else { + return None; + }; + + let proof = Self::build_asset_lock_proof(&tx, islock.as_ref(), chain_locked_height); + + Some((tx, address, amount, proof)) + } + + /// Constructs an AssetLockProof from an InstantLock or chain_locked_height. + fn build_asset_lock_proof( + tx: &Transaction, + islock: Option<&InstantLock>, + chain_locked_height: Option, + ) -> Option { + if let Some(islock) = islock { + Some(AssetLockProof::Instant(InstantAssetLockProof::new( + islock.clone(), + tx.clone(), + 0, + ))) + } else { + chain_locked_height.map(|height| { + AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height: height, + out_point: OutPoint::new(tx.txid(), 0), + }) + }) + } } fn render_asset_lock_info(&mut self, ui: &mut Ui) { let dark_mode = ui.ctx().style().visuals.dark_mode; - if let Some((tx, address, amount, _islock, proof)) = self.get_asset_lock_data() { + if let Some((tx, address, amount, proof)) = self.get_asset_lock_data() { Frame::new() .fill(DashColors::surface(dark_mode)) .corner_radius(5.0) @@ -104,7 +137,7 @@ impl AssetLockDetailScreen { ui.horizontal(|ui| { ui.label("Amount:"); - let dash_amount = amount.to_string().parse::().unwrap_or(0) as f64 * 1e-8; + let dash_amount = amount as f64 * 1e-8; ui.label(RichText::new(format!("{:.8} DASH ({} duffs)", dash_amount, amount)) .strong() .color(DashColors::text_primary(dark_mode))); @@ -216,7 +249,7 @@ impl AssetLockDetailScreen { let wallet = wallet_arc.read().unwrap(); // Find the private key for this address - if let Some(derivation_path) = wallet.known_addresses.get(&address).cloned() { + if let Some(derivation_path) = wallet.derivation_path_for_address(&address) { drop(wallet); // Release the read lock before getting write lock ui.horizontal(|ui| { diff --git a/src/ui/wallets/create_asset_lock_screen.rs b/src/ui/wallets/create_asset_lock_screen.rs index 9bd67ff26..70f3b6101 100644 --- a/src/ui/wallets/create_asset_lock_screen.rs +++ b/src/ui/wallets/create_asset_lock_screen.rs @@ -59,16 +59,13 @@ impl CreateAssetLockScreen { pub fn new(wallet: Arc>, app_context: &Arc) -> Self { let selected_wallet = Some(wallet.clone()); - // Calculate next unused identity index + // Calculate next unused identity index from the database let identity_index = { let wallet_guard = wallet.read().unwrap(); - wallet_guard - .identities - .keys() - .copied() - .max() - .map(|max| max + 1) - .unwrap_or(0) + let used = app_context + .db + .get_wallet_identity_indices(&wallet_guard.seed_hash(), app_context.network); + used.iter().copied().max().map(|max| max + 1).unwrap_or(0) }; Self { @@ -199,16 +196,14 @@ impl CreateAssetLockScreen { self.asset_lock_purpose = None; self.selected_identity = None; self.selected_identity_string.clear(); - // Recalculate next unused identity index + // Recalculate next unused identity index from the database self.identity_index = { let wallet_guard = self.wallet.read().unwrap(); - wallet_guard - .identities - .keys() - .copied() - .max() - .map(|max| max + 1) - .unwrap_or(0) + let used = self.app_context.db.get_wallet_identity_indices( + &wallet_guard.seed_hash(), + self.app_context.network, + ); + used.iter().copied().max().map(|max| max + 1).unwrap_or(0) }; self.top_up_index = 0; // Reset amount input to default 0.5 DASH @@ -472,9 +467,15 @@ impl ScreenLike for CreateAssetLockScreen { ui.heading(RichText::new("1. Index Selection").color(DashColors::text_primary(dark_mode))); ui.add_space(10.0); - // Get used indices from wallet + // Get used indices from the database let wallet_guard = self.wallet.read().unwrap(); - let used_indices: HashSet = wallet_guard.identities.keys().cloned().collect(); + let used_indices: HashSet = self + .app_context + .db + .get_wallet_identity_indices( + &wallet_guard.seed_hash(), + self.app_context.network, + ); drop(wallet_guard); egui::Grid::new("registration_advanced_options_grid") diff --git a/src/ui/wallets/import_mnemonic_screen.rs b/src/ui/wallets/import_mnemonic_screen.rs index 6082e35be..c22260caa 100644 --- a/src/ui/wallets/import_mnemonic_screen.rs +++ b/src/ui/wallets/import_mnemonic_screen.rs @@ -246,6 +246,15 @@ impl ImportMnemonicScreen { *pending = Some(new_wallet_seed_hash); } + // Register with PlatformWallet bridge (always, regardless of backend mode). + // Read seed bytes from the already-stored wallet. + if let Ok(guard) = wallet_arc.read() + && let Ok(seed_bytes) = guard.seed_bytes() + { + self.app_context + .register_with_platform_wallet_manager(new_wallet_seed_hash, *seed_bytes); + } + // Auto-discover identities derived from this wallet if self.identity_scan_count > 0 { self.app_context diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index a21f904dc..358e896af 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -569,8 +569,6 @@ impl WalletSendScreen { /// Deduplicates addresses based on their canonical Bech32m string representation, /// preferring the entry with the highest nonce (most recent update) fn get_platform_addresses(&self) -> Vec<(Address, PlatformAddress, Credits)> { - use std::collections::HashMap; - let Some(wallet_arc) = &self.selected_wallet else { return vec![]; }; @@ -579,39 +577,23 @@ impl WalletSendScreen { }; let network = self.app_context.network; - // Use HashMap to deduplicate by canonical address string - // Store (core_addr, platform_addr, balance, nonce) and prefer higher nonce - let mut address_map: HashMap = - HashMap::new(); - - for (addr, info) in wallet.platform_address_info.iter() { - if let Ok(platform_addr) = PlatformAddress::try_from(addr.clone()) { - let canonical_str = platform_addr.to_bech32m_string(network); - - // Check if we already have this address - let should_update = match address_map.get(&canonical_str) { - Some((_, _, _, existing_nonce)) => { - // Prefer the entry with higher nonce (more recent) - info.nonce >= *existing_nonce - } - None => true, - }; - - if should_update { - address_map.insert( - canonical_str, - (addr.clone(), platform_addr, info.balance, info.nonce), - ); - } - } - } + let db_info = self + .app_context + .db + .get_all_platform_address_info(&wallet.seed_hash(), &network) + .unwrap_or_default(); - // Filter to only addresses with positive balance, sort by canonical string, and return - let mut result: Vec<_> = address_map + let mut result: Vec<_> = db_info .into_iter() - .filter(|(_, (_, _, balance, _))| *balance > 0) - .map(|(canonical_str, (addr, platform_addr, balance, _))| { - (canonical_str, addr, platform_addr, balance) + .filter_map(|(core_addr, balance, _nonce)| { + if balance > 0 { + PlatformAddress::try_from(core_addr.clone()).ok().map(|pa| { + let canonical_str = pa.to_bech32m_string(network); + (canonical_str, core_addr, pa, balance) + }) + } else { + None + } }) .collect(); @@ -655,12 +637,16 @@ impl WalletSendScreen { } } - /// Get Core wallet balance + /// Get Core wallet balance (lock-free via `WalletBalance`). fn get_core_balance(&self) -> u64 { self.selected_wallet .as_ref() .and_then(|w| w.read().ok()) - .map(|w| w.confirmed_balance_duffs()) + .and_then(|g| { + g.platform_wallet + .as_ref() + .map(|pw| pw.core().balance().spendable()) + }) .unwrap_or(0) } @@ -684,17 +670,23 @@ impl WalletSendScreen { .collect() } - /// Get Core addresses with their UTXO balances + /// Get Core addresses with their UTXO balances (from PlatformWallet). fn get_core_addresses(&self) -> Vec<(Address, u64)> { - let Some(wallet_arc) = &self.selected_wallet else { - return vec![]; - }; - let Ok(wallet) = wallet_arc.read() else { + let Some(pw) = self + .selected_wallet + .as_ref() + .and_then(|w| w.read().ok()) + .and_then(|g| g.platform_wallet.clone()) + else { return vec![]; }; - - let mut addresses = wallet.utxos_by_address(); - // Sort by balance descending for better UX + let info = pw.state_blocking(); + let mut addresses: Vec<(Address, u64)> = + crate::platform_wallet_bridge::CoreAddressInfo::all_from_wallet_info(info.managed_state.wallet_info()) + .into_iter() + .filter(|a| a.balance > 0) + .map(|a| (a.address, a.balance)) + .collect(); addresses.sort_by(|a, b| b.1.cmp(&a.1)); addresses } @@ -2195,7 +2187,7 @@ impl WalletSendScreen { let all_wallets: Vec>> = wallets_guard.values().cloned().collect(); if !all_wallets.is_empty() { - builder = builder.with_wallets(&all_wallets); + builder = builder.with_wallets(&all_wallets, Some(&self.app_context.db)); } } @@ -2224,11 +2216,15 @@ impl WalletSendScreen { // Get max amount and hint based on source selection let (max_amount_credits, max_hint) = match &self.selected_source { Some(SourceSelection::CoreWallet) => { - let mut max = self.selected_wallet.as_ref().and_then(|w| { - w.read() - .ok() - .map(|wallet| wallet.total_balance_duffs() * CREDITS_PER_DUFF) // duffs to credits - }); + let mut max = self + .selected_wallet + .as_ref() + .and_then(|w| w.read().ok()) + .and_then(|g| { + g.platform_wallet + .as_ref() + .map(|pw| pw.core().balance().total() * CREDITS_PER_DUFF) + }); let dest_kind = self.destination_kind(); let hint = match dest_kind { Some(AddressKind::Platform) => { diff --git a/src/ui/wallets/shield_screen.rs b/src/ui/wallets/shield_screen.rs index 9d6f6234a..0ad4d3ca9 100644 --- a/src/ui/wallets/shield_screen.rs +++ b/src/ui/wallets/shield_screen.rs @@ -172,28 +172,44 @@ impl ShieldScreen { let wallet_guard = wallet_arc.read().ok(); if let Some(wallet) = &wallet_guard { - // Platform nonce and balance for selected address + // Platform nonce and balance for selected address from DB if let Some(from_address) = self.selected_platform_address() { - let info = wallet - .platform_address_info - .iter() - .find_map(|(addr, info)| { - let platform_addr = PlatformAddress::try_from(addr.clone()).ok()?; - (platform_addr == from_address).then_some(info) - }); - self.cached_base_nonce = info.map(|i| i.nonce); - self.cached_platform_balance = info.map(|i| i.balance); + let core_addr = from_address.to_address_with_network(self.app_context.network); + let db_info = self + .app_context + .db + .get_platform_address_info( + &wallet.seed_hash(), + &core_addr, + &self.app_context.network, + ) + .ok() + .flatten(); + self.cached_base_nonce = db_info.as_ref().map(|(_balance, nonce)| *nonce); + self.cached_platform_balance = db_info.as_ref().map(|(balance, _nonce)| *balance); } else { self.cached_base_nonce = None; self.cached_platform_balance = None; } - // Core balance + // Core balance — read from PlatformWallet (state_blocking for + // per-address lookup, lock-free WalletBalance for total). if let Some(addr) = self.validated_source.as_ref().and_then(|v| v.as_core()) { - self.cached_core_balance = - Some(wallet.address_balances.get(addr).copied().unwrap_or(0)); + let per_addr_balance = wallet.platform_wallet.as_ref().map(|pw| { + let info = pw.state_blocking(); + crate::platform_wallet_bridge::CoreAddressInfo::all_from_wallet_info(info.managed_state.wallet_info()) + .into_iter() + .find(|a| &a.address == addr) + .map(|a| a.balance) + .unwrap_or(0) + }); + self.cached_core_balance = Some(per_addr_balance.unwrap_or(0)); } else { - self.cached_core_balance = Some(wallet.total_balance_duffs()); + let pw_balance = wallet + .platform_wallet + .as_ref() + .map(|pw| pw.core().balance().total()); + self.cached_core_balance = Some(pw_balance.unwrap_or(0)); } } else { self.cached_base_nonce = None; @@ -715,7 +731,10 @@ impl ScreenLike for ShieldScreen { if let Ok(wallets) = self.app_context.wallets.read() && let Some(wallet) = wallets.get(&self.seed_hash) { - builder = builder.with_wallets(std::slice::from_ref(wallet)); + builder = builder.with_wallets( + std::slice::from_ref(wallet), + Some(&self.app_context.db), + ); } builder diff --git a/src/ui/wallets/unshield_credits_screen.rs b/src/ui/wallets/unshield_credits_screen.rs index baf766eda..995047ede 100644 --- a/src/ui/wallets/unshield_credits_screen.rs +++ b/src/ui/wallets/unshield_credits_screen.rs @@ -155,7 +155,7 @@ impl ScreenLike for UnshieldCreditsScreen { if let Ok(wallets) = self.app_context.wallets.read() { let all_wallets: Vec<_> = wallets.values().cloned().collect(); - builder = builder.with_wallets(&all_wallets); + builder = builder.with_wallets(&all_wallets, Some(&self.app_context.db)); } builder diff --git a/src/ui/wallets/wallets_screen/address_table.rs b/src/ui/wallets/wallets_screen/address_table.rs index af4348bbf..3b5cfe9b4 100644 --- a/src/ui/wallets/wallets_screen/address_table.rs +++ b/src/ui/wallets/wallets_screen/address_table.rs @@ -1,11 +1,12 @@ use crate::app::AppAction; use crate::model::wallet::{DerivationPathHelpers, DerivationPathReference}; +use crate::platform_wallet_bridge::CoreAddressInfo; use crate::ui::MessageType; use crate::ui::components::message_banner::MessageBanner; use crate::ui::wallets::account_summary::{AccountCategory, categorize_account_path}; 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}; +use dash_sdk::dpp::key_wallet::bip32::DerivationPath; use eframe::egui::{self, Ui}; use egui_extras::{Column, TableBuilder}; @@ -101,6 +102,65 @@ impl WalletsBalancesScreen { categorize_account_path(path, network, reference) } + /// Build `AddressData` from the cached `CoreAddressInfo` snapshot. + fn address_data_from_cache(cached: &[CoreAddressInfo], network: Network) -> Vec { + cached + .iter() + .map(|info| { + let derivation_path = &info.derivation_path; + let address_type = if derivation_path.is_bip44_external(network) { + "Funds".to_string() + } else if derivation_path.is_bip44_change(network) { + "Change".to_string() + } else if derivation_path.is_asset_lock_funding(network) { + "Identity Creation".to_string() + } else if derivation_path.is_platform_payment(network) { + "Platform".to_string() + } else { + "System".to_string() + }; + + // Use Unknown reference for cached data; categorize_account_path + // derives the category from the derivation path structure. + let (account_category, _category_account_index) = Self::categorize_path( + derivation_path, + DerivationPathReference::Unknown, + network, + ); + + AddressData { + address: info.address.clone(), + balance: info.balance, + platform_credits: 0, + utxo_count: info.utxo_count, + total_received: info.total_received, + nonce: 0, + address_type, + index: info.index, + derivation_path: derivation_path.clone(), + account_category, + account_index: info.account_index, + } + }) + .collect() + } + + /// Build `AddressData` directly from PlatformWallet's `ManagedWalletInfo` + /// (fallback when the async cache hasn't been populated yet). + fn address_data_from_platform_wallet(screen: &Self) -> Vec { + let pw = screen + .selected_wallet + .as_ref() + .and_then(|w| w.read().ok()) + .and_then(|g| g.platform_wallet.clone()); + let Some(pw) = pw else { + return Vec::new(); + }; + let info = pw.state_blocking(); + let cached = CoreAddressInfo::all_from_wallet_info(info.managed_state.wallet_info()); + Self::address_data_from_cache(&cached, screen.app_context.network) + } + pub(super) fn render_address_table( &mut self, ui: &mut Ui, @@ -108,94 +168,13 @@ impl WalletsBalancesScreen { ) -> AppAction { let action = AppAction::None; - // Move the data preparation into its own scope - let mut address_data = { - let wallet = self.selected_wallet.as_ref().unwrap().read().unwrap(); - - // Prepare data for the table - wallet - .known_addresses - .iter() - .map(|(address, derivation_path)| { - let utxo_info = wallet.utxos.get(address); - - let utxo_count = utxo_info.map(|outpoints| outpoints.len()).unwrap_or(0); - - // Get total received from the wallet (fetched from Core RPC) - let total_received = wallet - .address_total_received - .get(address) - .cloned() - .unwrap_or(0u64); - - let index = derivation_path - .into_iter() - .last() - .cloned() - .unwrap_or(ChildNumber::Normal { index: 0 }); - let index = match index { - ChildNumber::Normal { index } => index, - ChildNumber::Hardened { index } => index, - _ => 0, - }; - let address_type = - if derivation_path.is_bip44_external(self.app_context.network) { - "Funds".to_string() - } else if derivation_path.is_bip44_change(self.app_context.network) { - "Change".to_string() - } else if derivation_path.is_asset_lock_funding(self.app_context.network) { - "Identity Creation".to_string() - } else if derivation_path.is_platform_payment(self.app_context.network) { - "Platform".to_string() - } else { - "System".to_string() - }; - - let path_reference = wallet - .watched_addresses - .get(derivation_path) - .map(|info| info.path_reference) - .unwrap_or(DerivationPathReference::Unknown); - let (account_category, account_index) = Self::categorize_path( - derivation_path, - path_reference, - self.app_context.network, - ); - - // Get Platform credits balance and nonce for Platform Payment addresses only. - // Skip the lookup for non-platform addresses to avoid unnecessary linear - // scans in get_platform_address_info()'s fallback path. - let (platform_credits, nonce) = - if account_category == AccountCategory::PlatformPayment { - let platform_info = wallet.get_platform_address_info(address); - ( - platform_info.map(|info| info.balance).unwrap_or_default(), - platform_info.map(|info| info.nonce).unwrap_or_default(), - ) - } else { - (Default::default(), Default::default()) - }; - - AddressData { - address: address.clone(), - balance: wallet - .address_balances - .get(address) - .cloned() - .unwrap_or_default(), - platform_credits, - utxo_count, - total_received, - nonce, - address_type, - index, - derivation_path: derivation_path.clone(), - account_category, - account_index, - } - }) - .collect::>() - }; // The borrow of `wallet` ends here + // Build address data from cached CoreAddressInfo if available, + // otherwise fall back to the old direct-wallet-access path. + let mut address_data = if let Some(cached) = &self.cached_address_info { + Self::address_data_from_cache(cached, self.app_context.network) + } else { + Self::address_data_from_platform_wallet(self) + }; // Now you can use `self` mutably without conflict // Sort the data diff --git a/src/ui/wallets/wallets_screen/asset_locks.rs b/src/ui/wallets/wallets_screen/asset_locks.rs index da3b37ddc..c4f44c58e 100644 --- a/src/ui/wallets/wallets_screen/asset_locks.rs +++ b/src/ui/wallets/wallets_screen/asset_locks.rs @@ -1,7 +1,9 @@ use crate::app::AppAction; -use crate::model::wallet::DerivationPathHelpers; use crate::ui::ScreenType; use crate::ui::theme::{DashColors, ResponseExt}; +use dash_sdk::dpp::dashcore::Address; +use dash_sdk::dpp::dashcore::hashes::Hash; +use dash_sdk::dpp::dashcore::transaction::special_transaction::TransactionPayload; use eframe::egui::{self, Ui}; use egui::{Color32, Frame, Margin, RichText}; use egui_extras::{Column, TableBuilder}; @@ -11,12 +13,21 @@ use super::WalletsBalancesScreen; impl WalletsBalancesScreen { pub(super) fn render_wallet_asset_locks(&mut self, ui: &mut Ui) -> AppAction { let mut app_action = AppAction::None; - let mut open_fund_dialog_for_idx: Option<(usize, Vec<(String, u64)>)> = None; + let mut open_fund_dialog_for_txid: Option<([u8; 32], Vec<(String, u64)>)> = None; let mut recover_asset_locks_clicked = false; if let Some(arc_wallet) = &self.selected_wallet { let wallet = arc_wallet.read().unwrap(); + let network = self.app_context.network; + + // Read asset locks from the database (source of truth, includes consumed locks). + let locks = self + .app_context + .db + .get_asset_lock_transactions_for_wallet(&wallet.seed_hash(), network) + .unwrap_or_default(); + let dark_mode = ui.ctx().style().visuals.dark_mode; Frame::new() .fill(DashColors::surface(dark_mode)) @@ -40,7 +51,7 @@ impl WalletsBalancesScreen { }); ui.add_space(10.0); - if wallet.unused_asset_locks.is_empty() { + if locks.is_empty() { ui.vertical_centered(|ui| { ui.add_space(20.0); ui.label(RichText::new("No asset locks found").color(Color32::GRAY).size(14.0)); @@ -49,20 +60,16 @@ impl WalletsBalancesScreen { ui.add_space(20.0); }); } else { - // Collect Platform addresses for the fund dialog (using DIP-18 Bech32m format) - // Get from known_addresses where path is platform payment - let network = self.app_context.network; - let platform_addresses: Vec<(String, u64)> = wallet - .known_addresses - .iter() - .filter(|(_, path)| path.is_platform_payment(network)) - .filter_map(|(addr, _)| { + // Collect Platform addresses with balances from DB + let platform_addresses: Vec<(String, u64)> = self + .app_context + .db + .get_all_platform_address_info(&wallet.seed_hash(), &network) + .unwrap_or_default() + .into_iter() + .filter_map(|(core_addr, balance, _nonce)| { use dash_sdk::dpp::address_funds::PlatformAddress; - let balance = wallet - .get_platform_address_info(addr) - .map(|info| info.balance) - .unwrap_or(0); - PlatformAddress::try_from(addr.clone()) + PlatformAddress::try_from(core_addr) .ok() .map(|pa| (pa.to_bech32m_string(network), balance)) }) @@ -80,7 +87,7 @@ impl WalletsBalancesScreen { .column(Column::initial(100.0)) // Address .column(Column::initial(100.0)) // Amount (Duffs) .column(Column::initial(100.0)) // InstantLock status - .column(Column::initial(100.0)) // Usable status + .column(Column::initial(100.0)) // Consumed .column(Column::initial(200.0)) // Actions .header(30.0, |mut header| { header.col(|ui| { @@ -96,44 +103,62 @@ impl WalletsBalancesScreen { ui.label("InstantLock"); }); header.col(|ui| { - ui.label("Usable"); + ui.label("Status"); }); header.col(|ui| { ui.label("Actions"); }); }) .body(|mut body| { - for (index, (tx, address, amount, islock, proof)) in wallet.unused_asset_locks.iter().enumerate() { + for (tx, amount, islock, _chain_locked_height, identity_id) in &locks { + let txid = tx.txid(); + let txid_bytes = txid.to_byte_array(); + + let address_str = if let Some(TransactionPayload::AssetLockPayloadType(payload)) = &tx.special_transaction_payload { + payload.credit_outputs.first() + .and_then(|output| Address::from_script(&output.script_pubkey, network).ok()) + .map(|a| a.to_string()) + .unwrap_or_else(|| "Unknown".to_string()) + } else { + "Unknown".to_string() + }; + + let is_locked = islock.is_some(); + let is_consumed = identity_id.is_some(); + body.row(25.0, |mut row| { row.col(|ui| { - ui.label(tx.txid().to_string()); + ui.label(txid.to_string()); }); row.col(|ui| { - ui.label(address.to_string()); + ui.label(&address_str); }); row.col(|ui| { ui.label(format!("{}", amount)); }); row.col(|ui| { - let status = if islock.is_some() { "Yes" } else { "No" }; + let status = if is_locked { "Yes" } else { "No" }; ui.label(status); }); row.col(|ui| { - let status = if proof.is_some() { "Yes" } else { "No" }; - ui.label(status); + if is_consumed { + ui.label(RichText::new("Used").color(Color32::GRAY)); + } else { + ui.label(RichText::new("Available").color(DashColors::SUCCESS)); + } }); row.col(|ui| { if ui.small_button("View").clickable_tooltip("View full asset lock details").clicked() { app_action = AppAction::AddScreen( ScreenType::AssetLockDetail( wallet.seed_hash(), - index + txid_bytes, ).create_screen(&self.app_context) ); } - if proof.is_some() + if !is_consumed && is_locked && ui.small_button("Fund").clickable_tooltip("Fund a Platform address with this asset lock").clicked() { - open_fund_dialog_for_idx = Some((index, platform_addresses.clone())); + open_fund_dialog_for_txid = Some((txid_bytes, platform_addresses.clone())); } }); }); @@ -147,8 +172,8 @@ impl WalletsBalancesScreen { } // Handle dialog opening outside the borrow - if let Some((idx, platform_addresses)) = open_fund_dialog_for_idx { - self.fund_platform_dialog.selected_asset_lock_index = Some(idx); + if let Some((txid, platform_addresses)) = open_fund_dialog_for_txid { + self.fund_platform_dialog.selected_asset_lock_txid = Some(txid); self.fund_platform_dialog.is_open = true; self.fund_platform_dialog.platform_addresses = platform_addresses; self.fund_platform_dialog.selected_platform_address = None; diff --git a/src/ui/wallets/wallets_screen/dialogs.rs b/src/ui/wallets/wallets_screen/dialogs.rs index 6e10fe72e..98eb9b7ba 100644 --- a/src/ui/wallets/wallets_screen/dialogs.rs +++ b/src/ui/wallets/wallets_screen/dialogs.rs @@ -19,6 +19,7 @@ use dash_sdk::dashcore_rpc::dashcore::address::NetworkUnchecked; use dash_sdk::dashcore_rpc::dashcore::{Address, Network}; use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; use dash_sdk::dpp::key_wallet::bip32::DerivationPath; +use dash_sdk::dpp::prelude::AssetLockProof; use eframe::egui::{self, ComboBox, Context}; use eframe::epaint::TextureHandle; use egui::load::SizedTexture; @@ -72,8 +73,8 @@ pub(super) struct ReceiveDialogState { #[derive(Default)] pub(super) struct FundPlatformAddressDialogState { pub is_open: bool, - /// Selected asset lock index - pub selected_asset_lock_index: Option, + /// Selected asset lock txid (as byte array) + pub selected_asset_lock_txid: Option<[u8; 32]>, /// Selected Platform address to fund pub selected_platform_address: Option, /// List of Platform addresses available @@ -271,11 +272,7 @@ impl WalletsBalancesScreen { if let Ok(addr) = addr_str.parse::>() && let Ok(addr) = addr.require_network(self.app_context.network) { - *balance = wallet_guard - .address_balances - .get(&addr) - .copied() - .unwrap_or(0); + *balance = wallet_guard.address_balance(&addr); } } } @@ -661,16 +658,72 @@ impl WalletsBalancesScreen { &self, wallet: &Arc>, ) -> Result { + use crate::model::wallet::{ + DerivationPathHelpers, DerivationPathReference, DerivationPathType, + }; use dash_sdk::dpp::address_funds::PlatformAddress; - let mut wallet_guard = wallet.write().map_err(|e| e.to_string())?; - // Pass true to skip known addresses and generate a new one - let address = wallet_guard - .platform_receive_address(self.app_context.network, true, Some(&self.app_context)) + use dash_sdk::dpp::dashcore::secp256k1::Secp256k1; + use dash_sdk::dpp::key_wallet::bip32::DerivationPath; + + let wallet_guard = wallet.read().map_err(|e| e.to_string())?; + let pw = wallet_guard + .platform_wallet + .as_ref() + .ok_or_else(|| "Wallet is locked".to_string())?; + let network = self.app_context.network; + + // Find the highest existing platform payment address index + let info = pw.state_blocking(); + let existing_indices: Vec = + crate::platform_wallet_bridge::CoreAddressInfo::all_from_wallet_info(info.managed_state.wallet_info()) + .iter() + .filter(|a| a.derivation_path.is_platform_payment(network)) + .filter_map(|a| { + use dash_sdk::dpp::key_wallet::bip32::ChildNumber; + a.derivation_path + .as_ref() + .last() + .and_then(|child| match child { + ChildNumber::Normal { index } | ChildNumber::Hardened { index } => { + Some(*index) + } + _ => None, + }) + }) + .collect(); + + let next_index = existing_indices.iter().max().map(|m| m + 1).unwrap_or(0); + + // Derive a new platform payment address + let seed = *wallet_guard.seed_bytes().map_err(|e| e.to_string())?; + let secp = Secp256k1::new(); + let derivation_path = DerivationPath::platform_payment_path(network, 0, 0, next_index); + let extended_private_key = derivation_path + .derive_priv_ecdsa_for_master_seed(&seed, network) + .map_err(|e| e.to_string())?; + let private_key = extended_private_key.to_priv(); + let public_key = private_key.public_key(&secp); + let address = dash_sdk::dpp::dashcore::Address::p2pkh(&public_key, network); + + // Persist to DB + let canonical = Wallet::canonical_address(&address, network); + self.app_context + .db + .add_address_if_not_exists( + &wallet_guard.seed_hash(), + &canonical, + &network, + &derivation_path, + DerivationPathReference::PlatformPayment, + DerivationPathType::CLEAR_FUNDS, + None, + ) .map_err(|e| e.to_string())?; + // Convert to PlatformAddress and encode as Bech32m per DIP-18 let platform_addr = PlatformAddress::try_from(address).map_err(|e| format!("Invalid address: {}", e))?; - Ok(platform_addr.to_bech32m_string(self.app_context.network)) + Ok(platform_addr.to_bech32m_string(network)) } /// Generate a new Core receive address for the wallet @@ -683,11 +736,7 @@ impl WalletsBalancesScreen { let address = wallet_guard .receive_address(self.app_context.network, true, Some(&self.app_context)) .map_err(|e| e.to_string())?; - let balance = wallet_guard - .address_balances - .get(&address) - .copied() - .unwrap_or(0); + let balance = wallet_guard.address_balance(&address); Ok((address.to_string(), balance)) } @@ -790,7 +839,7 @@ impl WalletsBalancesScreen { // Buttons ui.horizontal(|ui| { let can_fund = self.fund_platform_dialog.selected_platform_address.is_some() - && self.fund_platform_dialog.selected_asset_lock_index.is_some() + && self.fund_platform_dialog.selected_asset_lock_txid.is_some() && !self.fund_platform_dialog.is_processing; // Cancel button @@ -999,13 +1048,13 @@ impl WalletsBalancesScreen { return AppAction::None; }; - let Some(asset_lock_idx) = self.fund_platform_dialog.selected_asset_lock_index else { + let Some(asset_lock_txid) = self.fund_platform_dialog.selected_asset_lock_txid else { self.fund_platform_dialog.status = Some("No asset lock selected".to_string()); self.fund_platform_dialog.status_is_error = true; return AppAction::None; }; - // Get the asset lock proof and address from the wallet + // Get the asset lock proof and address from the database let (seed_hash, asset_lock_proof, asset_lock_address, platform_addr) = { let wallet = match wallet_arc.read() { Ok(guard) => guard, @@ -1016,14 +1065,57 @@ impl WalletsBalancesScreen { } }; - let asset_lock = wallet.unused_asset_locks.get(asset_lock_idx); - let Some((_, addr, _, _, Some(proof))) = asset_lock else { + // Read from the database (source of truth for all asset locks). + let db_record = self + .app_context + .db + .get_asset_lock_transaction(&asset_lock_txid); + let Some(( + tx, + _amount, + islock, + chain_locked_height, + _identity_id, + _wallet_seed, + _network, + )) = db_record.ok().flatten() + else { self.fund_platform_dialog.status = Some("Asset lock not found or not ready".to_string()); self.fund_platform_dialog.status_is_error = true; return AppAction::None; }; + // Build proof from IS-lock or chain-locked height + let proof = if let Some(ref islock) = islock { + use dash_sdk::dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; + AssetLockProof::Instant(InstantAssetLockProof::new(islock.clone(), tx.clone(), 0)) + } else if let Some(height) = chain_locked_height { + use dash_sdk::dpp::dashcore::OutPoint; + use dash_sdk::dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; + AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height: height, + out_point: OutPoint::new(tx.txid(), 0), + }) + } else { + self.fund_platform_dialog.status = + Some("Asset lock proof not yet available".to_string()); + self.fund_platform_dialog.status_is_error = true; + return AppAction::None; + }; + + // Derive address from credit output + let addr = if let Some(dash_sdk::dpp::dashcore::transaction::special_transaction::TransactionPayload::AssetLockPayloadType(payload)) = &tx.special_transaction_payload { + payload.credit_outputs.first() + .and_then(|output| dash_sdk::dpp::dashcore::Address::from_script(&output.script_pubkey, self.app_context.network).ok()) + .unwrap_or_else(|| dash_sdk::dpp::dashcore::Address::from_script(&tx.output[0].script_pubkey, self.app_context.network).unwrap()) + } else { + self.fund_platform_dialog.status = + Some("Could not derive address from asset lock".to_string()); + self.fund_platform_dialog.status_is_error = true; + return AppAction::None; + }; + // Parse the Platform address (Bech32m format: dash1.../tdash1... per DIP-18) let platform_addr = if crate::ui::helpers::is_platform_address_string(selected_addr) { match PlatformAddress::from_bech32m_string(selected_addr) { @@ -1111,8 +1203,13 @@ impl WalletsBalancesScreen { } { - let wallet_guard = wallet.read().map_err(|e| e.to_string())?; - if amount_duffs > wallet_guard.confirmed_balance_duffs() { + let guard = wallet.read().map_err(|e| e.to_string())?; + let spendable = guard + .platform_wallet + .as_ref() + .map(|pw| pw.core().balance().spendable()) + .unwrap_or(0); + if amount_duffs > spendable { return Err("Insufficient confirmed balance".to_string()); } } @@ -1169,25 +1266,26 @@ impl WalletsBalancesScreen { } /// Load BIP44 external addresses with balances from a wallet. + /// Uses PlatformWallet's CoreAddressInfo as the canonical source. + /// Locked wallets (no PlatformWallet) return empty. fn load_bip44_external_addresses( &self, wallet: &Arc>, ) -> Result, String> { let wallet_guard = wallet.read().map_err(|e| e.to_string())?; let network = self.app_context.network; - let 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(); + + let addresses: Vec<(String, u64)> = if let Some(pw) = wallet_guard.platform_wallet.as_ref() + { + let info = pw.state_blocking(); + crate::platform_wallet_bridge::CoreAddressInfo::all_from_wallet_info(info.managed_state.wallet_info()) + .into_iter() + .filter(|a| a.derivation_path.is_bip44_external(network)) + .map(|a| (a.address.to_string(), a.balance)) + .collect() + } else { + Vec::new() + }; Ok(addresses) } @@ -1227,18 +1325,19 @@ impl WalletsBalancesScreen { }; // Collect Platform addresses with their balances (using DIP-18 Bech32m format) - // Use platform_addresses() which checks watched_addresses, not just platform_address_info - // This includes addresses that have been derived but may not have been synced yet let network = self.app_context.network; - let platform_addresses: Vec<(String, u64)> = wallet_guard - .platform_addresses(network) + let db_info = self + .app_context + .db + .get_all_platform_address_info(&wallet_guard.seed_hash(), &network) + .unwrap_or_default(); + let platform_addresses: Vec<(String, u64)> = db_info .into_iter() - .map(|(core_addr, platform_addr)| { - let balance = wallet_guard - .get_platform_address_info(&core_addr) - .map(|info| info.balance) - .unwrap_or(0); - (platform_addr.to_bech32m_string(network), balance) + .filter_map(|(core_addr, balance, _nonce)| { + use dash_sdk::dpp::address_funds::PlatformAddress; + PlatformAddress::try_from(core_addr) + .ok() + .map(|pa| (pa.to_bech32m_string(network), balance)) }) .collect(); @@ -1288,7 +1387,7 @@ impl WalletsBalancesScreen { let address_input = AddressInput::new(self.app_context.network) .with_label("Mine to address:") .with_address_kinds(&[AddressKind::Core]) - .with_wallets(&[wallet]) + .with_wallets(&[wallet], Some(&self.app_context.db)) .with_selection_only(true) .with_full_addresses(true); diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 60f36c81c..1e4bd8c47 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -8,11 +8,13 @@ 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::backend_task::wallet::WalletTask; use crate::context::AppContext; use crate::context::connection_status::spv_phase_summary; use crate::model::amount::Amount; use crate::model::feature_gate::FeatureGate; use crate::model::wallet::{TransactionStatus, Wallet, WalletSeedHash, WalletTransaction}; +use crate::platform_wallet_bridge::CoreAddressInfo; use crate::spv::{CoreBackendMode, SpvStatus}; use crate::ui::components::component_trait::Component; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; @@ -157,6 +159,8 @@ 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 per-address info from the CoreWallet bridge (populated asynchronously) + cached_address_info: Option>, /// Cached filtered transaction indices for the currently selected wallet. /// Invalidated (set to None) on wallet switch or transaction updates. cached_tx_indices: Option>, @@ -220,16 +224,15 @@ impl WalletsBalancesScreen { selected_wallet: Option>>, selected_single_key_wallet: Option>>, ) -> Self { - let platform_sync_info = selected_wallet + let seed_hash = selected_wallet .as_ref() - .and_then(|w| w.read().ok().map(|g| g.seed_hash())) + .and_then(|w| w.read().ok().map(|g| g.seed_hash())); + + let platform_sync_info = seed_hash .and_then(|hash| app_context.db.get_platform_sync_info(&hash).ok()) .filter(|(ts, _)| *ts > 0); - 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)); + let shielded_tab_view = seed_hash.map(|hash| ShieldedTabView::new(app_context, hash)); Self { selected_wallet, @@ -260,17 +263,18 @@ impl WalletsBalancesScreen { asset_lock_search_banner: None, utxo_page: 0, refresh_mode: RefreshMode::default(), - selected_account_tab: AccountTab::default(), - shielded_tab_view, platform_sync_info, core_wallet_dialog: None, 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, + cached_address_info: None, + selected_account_tab: AccountTab::default(), + shielded_tab_view, + pending_wallet_refresh_on_switch: false, cached_tx_indices: None, cached_tx_source_len: None, } @@ -371,6 +375,7 @@ impl WalletsBalancesScreen { self.selected_wallet = wallet; self.selected_single_key_wallet = None; self.selected_account = None; + self.cached_address_info = None; self.selected_account_tab = AccountTab::default(); self.cached_tx_indices = None; self.cached_tx_source_len = None; @@ -401,6 +406,7 @@ impl WalletsBalancesScreen { self.selected_wallet = None; self.selected_account = None; self.platform_sync_info = None; + self.cached_address_info = None; self.utxo_page = 0; if let Ok(hash) = wallet.read().map(|g| g.key_hash) { @@ -523,9 +529,18 @@ impl WalletsBalancesScreen { if let Ok(wallets_guard) = self.app_context.wallets.read() { for wallet in wallets_guard.values() { let guard = wallet.read().unwrap(); - let core_balance = guard.total_balance_duffs(); - let platform_balance = Self::platform_balance_duffs(&guard); - let shielded_balance = self.shielded_balance_duffs(&guard.seed_hash()); + let seed_hash = guard.seed_hash(); + let core_balance = guard + .platform_wallet + .as_ref() + .map(|pw| pw.core().balance().total()) + .unwrap_or(0); + let platform_balance = Self::platform_balance_duffs( + &guard, + &self.app_context.db, + &self.app_context.network, + ); + let shielded_balance = self.shielded_balance_duffs(&seed_hash); let balance_dash = (core_balance + platform_balance + shielded_balance) as f64 * 1e-8; let label = format!( @@ -589,8 +604,12 @@ impl WalletsBalancesScreen { .read() .ok() .map(|g| { - let core = g.total_balance_duffs(); - let platform = Self::platform_balance_duffs(&g); + let core = self.core_balance_duffs(); + let platform = Self::platform_balance_duffs( + &g, + &self.app_context.db, + &self.app_context.network, + ); let shielded = self.shielded_balance_duffs(&g.seed_hash()); core + platform + shielded }) @@ -1025,13 +1044,31 @@ impl WalletsBalancesScreen { .unwrap_or_else(|| "Unknown".to_string()) } - fn platform_balance_duffs(wallet: &Wallet) -> u64 { - // Only sum Platform address balances + /// Core balance from the `PlatformWallet`'s lock-free `WalletBalance`. + /// Returns 0 if no platform wallet is available. + fn core_balance_duffs(&self) -> u64 { + self.selected_wallet + .as_ref() + .and_then(|w| w.read().ok()) + .and_then(|g| { + g.platform_wallet + .as_ref() + .map(|pw| pw.core().balance().total()) + }) + .unwrap_or(0) + } + + fn platform_balance_duffs( + wallet: &Wallet, + db: &crate::database::Database, + network: &dash_sdk::dpp::dashcore::Network, + ) -> u64 { + // Only sum Platform address balances from DB // Identity balances are shown separately on the Identities screen - wallet - .platform_address_info - .values() - .map(|info| info.balance / CREDITS_PER_DUFF) + db.get_all_platform_address_info(&wallet.seed_hash(), network) + .unwrap_or_default() + .iter() + .map(|(_addr, balance, _nonce)| balance / CREDITS_PER_DUFF) .sum() } @@ -1207,7 +1244,7 @@ impl WalletsBalancesScreen { ]; // Precompute per-category address counts in a single pass over - // watched_addresses to avoid O(num_categories * num_addresses) + // PlatformWallet addresses to avoid O(num_categories * num_addresses) // per frame. let address_counts = self.precompute_address_counts(); @@ -1238,9 +1275,9 @@ impl WalletsBalancesScreen { sections } - /// 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. + /// Build a per-category address count map in a single pass. + /// Uses PlatformWallet's CoreAddressInfo as the canonical source. + /// Locked wallets (no PlatformWallet) return empty counts. 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 { @@ -1250,13 +1287,17 @@ impl WalletsBalancesScreen { return counts; }; let network = self.app_context.network; - 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; + + if let Some(pw) = wallet.platform_wallet.as_ref() { + let info = pw.state_blocking(); + for addr_info in crate::platform_wallet_bridge::CoreAddressInfo::all_from_wallet_info(info.managed_state.wallet_info()) { + let (cat, _) = crate::ui::wallets::account_summary::categorize_account_path( + &addr_info.derivation_path, + network, + crate::model::wallet::DerivationPathReference::Unknown, + ); + *counts.entry(cat).or_insert(0) += 1; + } } counts } @@ -1542,7 +1583,9 @@ impl WalletsBalancesScreen { return; } - if wallet_guard.transactions.is_empty() { + let transactions = wallet_guard.get_transactions(); + + if transactions.is_empty() { ui.label( "No transactions found. Try refreshing your wallet to load transaction history.", ); @@ -1553,7 +1596,7 @@ impl WalletsBalancesScreen { // The `is_ours` flag is set by both RPC and SPV paths for all // transactions that belong to this wallet (sends and receives). // Invalidate cache when source tx count changes or indices go stale. - let tx_len = wallet_guard.transactions.len(); + let tx_len = transactions.len(); if self.cached_tx_source_len != Some(tx_len) || self .cached_tx_indices @@ -1563,11 +1606,9 @@ impl WalletsBalancesScreen { self.cached_tx_indices = None; self.cached_tx_source_len = Some(tx_len); } - let relevant_indices = self.cached_tx_indices.get_or_insert_with(|| { - (0..tx_len) - .filter(|&i| wallet_guard.transactions[i].is_ours) - .collect() - }); + let relevant_indices = self + .cached_tx_indices + .get_or_insert_with(|| (0..tx_len).filter(|&i| transactions[i].is_ours).collect()); if relevant_indices.is_empty() { ui.label( @@ -1580,14 +1621,10 @@ impl WalletsBalancesScreen { let show_fee = self.app_context.is_developer_mode(); let mut order: Vec = relevant_indices.clone(); order.sort_by(|&a, &b| { - wallet_guard.transactions[b] + transactions[b] .timestamp - .cmp(&wallet_guard.transactions[a].timestamp) - .then_with(|| { - wallet_guard.transactions[b] - .txid - .cmp(&wallet_guard.transactions[a].txid) - }) + .cmp(&transactions[a].timestamp) + .then_with(|| transactions[b].txid.cmp(&transactions[a].txid)) }); let row_height = 26.0; @@ -1653,7 +1690,7 @@ impl WalletsBalancesScreen { }) .body(|mut body| { for idx in order { - let tx = &wallet_guard.transactions[idx]; + let tx = &transactions[idx]; body.row(row_height, |mut row| { row.col(|ui| { ui.label(Self::format_transaction_timestamp(tx.timestamp)); @@ -1752,7 +1789,7 @@ impl WalletsBalancesScreen { } } CoreBackendMode::Spv => { - let snapshot = self.app_context.spv_manager().status(); + let snapshot = self.app_context.spv_event_bridge().status(); match snapshot.status { SpvStatus::Idle | SpvStatus::Stopped => { ui.label( @@ -1814,7 +1851,16 @@ impl WalletsBalancesScreen { .selected_wallet .as_ref() .and_then(|w| w.read().ok()) - .map(|w| w.platform_address_info.len()) + .map(|w| { + self.app_context + .db + .get_all_platform_address_info( + &w.seed_hash(), + &self.app_context.network, + ) + .unwrap_or_default() + .len() + }) .unwrap_or(0); let addr_color = if self.refreshing { syncing_color @@ -1926,8 +1972,9 @@ impl WalletsBalancesScreen { /// 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 core_balance = self.core_balance_duffs(); + let platform_balance = + Self::platform_balance_duffs(wallet, &self.app_context.db, &self.app_context.network); let shielded_balance = self.shielded_balance_duffs(&wallet.seed_hash()); let total = core_balance + platform_balance + shielded_balance; @@ -1942,8 +1989,9 @@ impl WalletsBalancesScreen { /// 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 core_balance = self.core_balance_duffs(); + let platform_balance = + Self::platform_balance_duffs(wallet, &self.app_context.db, &self.app_context.network); let shielded_balance = self.shielded_balance_duffs(&wallet.seed_hash()); let header = egui::CollapsingHeader::new( @@ -2050,7 +2098,11 @@ impl WalletsBalancesScreen { let summaries = { let wallet = wallet_arc.read().unwrap(); - collect_account_summaries(&wallet, self.app_context.network) + collect_account_summaries( + &wallet, + self.app_context.network, + &self.app_context.db, + ) }; self.ensure_account_selection(&summaries); action |= self.render_account_tabs(ui, &summaries); @@ -2184,12 +2236,35 @@ impl WalletsBalancesScreen { impl ScreenLike for WalletsBalancesScreen { fn ui(&mut self, ctx: &Context) -> AppAction { // 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 }, - )) + let pending_refresh_action = + if let Some(seed_hash) = self.pending_platform_balance_refresh.take() { + AppAction::BackendTask(BackendTask::WalletTask( + WalletTask::FetchPlatformAddressBalances { seed_hash }, + )) + } else { + AppAction::None + }; + + // Dispatch LoadAddressInfo when the cache is empty and a wallet is selected + let load_address_info_action = if self.cached_address_info.is_none() { + self.selected_wallet + .as_ref() + .and_then(|w| { + w.read() + .ok() + .map(|g| (g.seed_hash(), g.platform_wallet.is_some())) + }) + .and_then(|(seed_hash, has_pw)| { + // Only dispatch if the platform wallet is available + if has_pw { + Some(AppAction::BackendTask(BackendTask::WalletTask( + WalletTask::LoadAddressInfo { seed_hash }, + ))) + } else { + None + } + }) + .unwrap_or(AppAction::None) } else { AppAction::None }; @@ -2692,6 +2767,7 @@ impl ScreenLike for WalletsBalancesScreen { // Combine with pending actions action |= pending_refresh_action; + action |= load_address_info_action; action |= pending_switch_action; action |= shielded_tick_action; action @@ -2759,6 +2835,8 @@ impl ScreenLike for WalletsBalancesScreen { match backend_task_success_result { crate::ui::BackendTaskSuccessResult::RefreshedWallet { warning } => { self.refreshing = false; + // Invalidate cached address info so it's re-fetched from the bridge + self.cached_address_info = None; self.cached_tx_indices = None; self.cached_tx_source_len = None; // Refresh the cached platform sync info so the panel shows @@ -2828,12 +2906,18 @@ impl ScreenLike for WalletsBalancesScreen { && let Ok(wallet) = selected.read() && wallet.seed_hash() == seed_hash { - // Parse address and get balance + // Parse address and get balance from PlatformWallet let balance = address .parse::>() .ok() + .map(|addr| addr.assume_checked()) .and_then(|addr| { - wallet.address_balances.get(&addr.assume_checked()).copied() + let pw = wallet.platform_wallet.as_ref()?; + let info = pw.state_blocking(); + crate::platform_wallet_bridge::CoreAddressInfo::all_from_wallet_info(info.managed_state.wallet_info()) + .into_iter() + .find(|a| a.address == addr) + .map(|a| a.balance) }) .unwrap_or(0); self.receive_dialog @@ -2874,19 +2958,11 @@ impl ScreenLike for WalletsBalancesScreen { } crate::ui::BackendTaskSuccessResult::PlatformAddressBalances { seed_hash, - balances, + balances: _, } => { self.refreshing = false; - // Update wallet's platform_address_info if this is for the selected wallet - if let Some(selected) = &self.selected_wallet - && let Ok(mut wallet) = selected.write() - && wallet.seed_hash() == seed_hash - { - // Update balances in the wallet - for (addr, (balance, nonce)) in balances { - wallet.set_platform_address_info(addr, balance, nonce); - } - } + // Platform address balances are persisted to DB by the backend task. + // No in-memory wallet update needed. self.refresh_platform_sync_info_cache(&seed_hash); MessageBanner::set_global( self.app_context.egui_ctx(), @@ -2894,6 +2970,9 @@ impl ScreenLike for WalletsBalancesScreen { MessageType::Success, ); } + crate::ui::BackendTaskSuccessResult::AddressInfo(info) => { + self.cached_address_info = Some(info); + } crate::ui::BackendTaskSuccessResult::Message(msg) => { self.refreshing = false; MessageBanner::set_global(self.app_context.egui_ctx(), &msg, MessageType::Success); @@ -3018,6 +3097,7 @@ impl ScreenLike for WalletsBalancesScreen { fn refresh(&mut self) { self.refreshing = false; + self.cached_address_info = None; self.refresh_on_arrival(); } } diff --git a/tests/backend-e2e/framework/cleanup.rs b/tests/backend-e2e/framework/cleanup.rs index 586493169..7dc000e43 100644 --- a/tests/backend-e2e/framework/cleanup.rs +++ b/tests/backend-e2e/framework/cleanup.rs @@ -22,14 +22,16 @@ pub async fn cleanup_test_wallets( app_context: &Arc, framework_wallet_hash: WalletSeedHash, ) { - // Framework wallet receive address - let framework_address = { + // Framework wallet receive address — extract Arc before awaiting to avoid + // holding std::sync::RwLock across .await (deadlocks with SPV's tokio lock). + let framework_wallet = { let wallets = app_context.wallets().read().expect("wallets lock"); - let framework_wallet = wallets + wallets .get(&framework_wallet_hash) - .expect("framework wallet must exist"); - get_receive_address(app_context, framework_wallet) + .expect("framework wallet must exist") + .clone() }; + let framework_address = get_receive_address(app_context, &framework_wallet).await; // Collect non-framework wallet hashes let wallet_hashes: Vec = { diff --git a/tests/backend-e2e/framework/funding.rs b/tests/backend-e2e/framework/funding.rs index 84ee0e379..36e3b2b4d 100644 --- a/tests/backend-e2e/framework/funding.rs +++ b/tests/backend-e2e/framework/funding.rs @@ -14,7 +14,14 @@ const MIN_BALANCE_DUFFS: u64 = 1_000_000_000; // 10 DASH /// If the balance is below the threshold, panics with the receive address /// and instructions for the user to fund it manually. pub async fn verify_framework_funded(app_context: &Arc, wallet_hash: WalletSeedHash) { - let (current_balance, address) = get_wallet_balance_and_address(app_context, wallet_hash); + // Read balance from lock-free atomics (no lock needed). + let current_balance = { + let wallets = app_context.wallets().read().expect("wallets lock"); + wallets + .get(&wallet_hash) + .map(|w| w.read().expect("wallet lock").total_balance_duffs()) + .unwrap_or(0) + }; if current_balance >= MIN_BALANCE_DUFFS { tracing::info!( @@ -24,6 +31,8 @@ pub async fn verify_framework_funded(app_context: &Arc, wallet_hash: return; } + // Only derive address in the panic path (rare). + let address = get_wallet_balance_and_address(app_context, wallet_hash).await.1; panic!( "Framework wallet balance is below minimum ({} duffs < {} duffs).\n\ Fund this address manually: {}\n\ @@ -33,23 +42,28 @@ pub async fn verify_framework_funded(app_context: &Arc, wallet_hash: } /// Get the wallet's current total balance and receive address. -fn get_wallet_balance_and_address( +async fn get_wallet_balance_and_address( app_context: &Arc, wallet_hash: WalletSeedHash, ) -> (u64, String) { - let wallets = app_context.wallets().read().expect("wallets lock"); - let wallet_arc = wallets - .get(&wallet_hash) - .expect("framework wallet must exist"); - - let mut wallet = wallet_arc.write().expect("wallet lock"); - let balance = wallet.total_balance_duffs(); - let address = wallet - .receive_address( - dash_sdk::dpp::dashcore::Network::Testnet, - false, - Some(app_context), - ) + // Extract balance and PlatformWallet under short sync lock, then drop + // the guard before any .await to avoid holding std::sync::RwLock across + // an async yield point (which would deadlock with SPV reconcile). + let (balance, pw) = { + let wallets = app_context.wallets().read().expect("wallets lock"); + let wallet_arc = wallets + .get(&wallet_hash) + .expect("framework wallet must exist"); + let wallet = wallet_arc.read().expect("wallet lock"); + let balance = wallet.total_balance_duffs(); + let pw = wallet.platform_wallet.clone().expect("platform wallet"); + (balance, pw) + }; + + let address = pw + .core() + .next_receive_address() + .await .expect("Failed to get receive address") .to_string(); (balance, address) diff --git a/tests/backend-e2e/framework/harness.rs b/tests/backend-e2e/framework/harness.rs index c04db4f1b..77f9e27a9 100644 --- a/tests/backend-e2e/framework/harness.rs +++ b/tests/backend-e2e/framework/harness.rs @@ -90,16 +90,17 @@ impl BackendTestContext { tracing::debug!(".env not loaded ({e}), relying on environment"); } - // Persistent workdir keyed by git revision - let git_hash = std::process::Command::new("git") - .args(["rev-parse", "--short", "HEAD"]) - .output() - .ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|s| s.trim().to_string()) - .unwrap_or_else(|| "unknown".to_string()); - - let workdir = std::env::temp_dir().join(format!("dash-evo-e2e-testnet-{}", git_hash)); + // Persistent workdir — stable across commits so SPV header/filter + // cache is reused. Wallet state (UTXOs, balances) is NOT persisted + // yet (apply() TODO), so each run must rescan filters from birth_height. + // We clear the SPV state directory to reset filter_committed_height, + // but keep headers/filters cached for fast re-download. + let workdir = std::env::temp_dir().join("dash-evo-e2e-testnet"); + // TODO: Once apply() restores wallet state from persistence, remove + // this filter_committed_height reset. Currently wallet UTXOs/balances + // are lost on restart, so we must force a filter rescan from + // birth_height each run. We do NOT delete the SPV cache — headers + // and filters take ~90 min to re-download from scratch. std::fs::create_dir_all(&workdir).expect("Failed to create workdir"); tracing::info!("E2E workdir: {}", workdir.display()); @@ -128,8 +129,82 @@ impl BackendTestContext { ) .expect("Failed to create AppContext for testnet"); - // Switch to SPV mode and start + // E2E_WALLET_MNEMONIC is required + let mnemonic_phrase = std::env::var("E2E_WALLET_MNEMONIC").unwrap_or_else(|_| { + panic!( + "E2E_WALLET_MNEMONIC is not set.\n\ + This environment variable is required for backend E2E tests.\n\ + Set it to a BIP-39 mnemonic of a pre-funded testnet wallet.\n\ + Example: E2E_WALLET_MNEMONIC=\"word1 word2 word3 ... word12\"\n\ + You can also add it to the project root .env file." + ); + }); + + tracing::info!("Restoring framework wallet from E2E_WALLET_MNEMONIC"); + let mnemonic = Mnemonic::parse_in(Language::English, &mnemonic_phrase) + .expect("Invalid E2E_WALLET_MNEMONIC"); + + let seed = mnemonic.to_seed(""); + let wallet = dash_evo_tool::model::wallet::Wallet::new_from_seed( + seed, + Network::Testnet, + Some("E2E Framework Wallet".to_string()), + None, + ) + .expect("Failed to create framework wallet"); + + let framework_wallet_hash = wallet.seed_hash(); + + // Register wallet BEFORE starting SPV so the wallet's addresses + // are in the bloom filter from the start. This is important because + // the SPV filter scan checks monitored_addresses() once at startup. + match app_context.register_wallet(wallet) { + Ok((hash, _)) => { + tracing::info!("Registered framework wallet (seed_hash: {:?})", &hash[..4]); + } + Err(TaskError::WalletAlreadyImported) => { + tracing::info!("Framework wallet already registered (reusing from persistent DB)"); + } + Err(e) => panic!("Failed to register framework wallet: {}", e), + } + + // Set birth_height on the framework wallet so SPV filter scanning + // starts from a recent block instead of genesis. Without this, a + // fresh testnet scan (~1.4M blocks) takes >90 minutes and exceeds + // the test timeout. E2E_WALLET_BIRTH_HEIGHT can override the default. + { + use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + + let birth_height: u32 = std::env::var("E2E_WALLET_BIRTH_HEIGHT") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(1_400_000); + + // Access the PlatformWallet through the old Wallet model. + let wallets = app_context.wallets().read().expect("wallets lock"); + if let Some(wallet_arc) = wallets.get(&framework_wallet_hash) { + let wallet_guard = wallet_arc.read().expect("wallet lock"); + if let Some(pw) = &wallet_guard.platform_wallet { + if let Some(mut wi) = pw.try_state_mut() { + if wi.managed_state.wallet_info().birth_height() == 0 { + wi.managed_state.wallet_info_mut().set_birth_height(birth_height); + tracing::info!("Set framework wallet birth_height to {}", birth_height); + } + } + } + } + } + + // Switch to SPV mode and start (wallet already registered above) app_context.set_core_backend_mode(CoreBackendMode::Spv); + // Reset filter_committed_height so the filter scan restarts from + // birth_height. Without this, cached committed height from a previous + // run causes the scan to skip historical blocks, and since wallet + // state isn't persisted yet (apply() TODO), the balance stays 0. + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current() + .block_on(app_context.reset_spv_filter_committed_height()); + }); app_context.start_spv().expect("Failed to start SPV"); // Stash the cancellation token so the panic hook can stop SPV if @@ -168,55 +243,25 @@ impl BackendTestContext { .expect("SPV failed to connect to any peers within 60s"); tracing::info!("SPV connected to peers"); - // E2E_WALLET_MNEMONIC is required - let mnemonic_phrase = std::env::var("E2E_WALLET_MNEMONIC").unwrap_or_else(|_| { - panic!( - "E2E_WALLET_MNEMONIC is not set.\n\ - This environment variable is required for backend E2E tests.\n\ - Set it to a BIP-39 mnemonic of a pre-funded testnet wallet.\n\ - Example: E2E_WALLET_MNEMONIC=\"word1 word2 word3 ... word12\"\n\ - You can also add it to the project root .env file." - ); - }); - - tracing::info!("Restoring framework wallet from E2E_WALLET_MNEMONIC"); - let mnemonic = Mnemonic::parse_in(Language::English, &mnemonic_phrase) - .expect("Invalid E2E_WALLET_MNEMONIC"); - - let seed = mnemonic.to_seed(""); - let wallet = dash_evo_tool::model::wallet::Wallet::new_from_seed( - seed, - Network::Testnet, - Some("E2E Framework Wallet".to_string()), - None, - ) - .expect("Failed to create framework wallet"); - - let framework_wallet_hash = wallet.seed_hash(); - - // Try to register; if the wallet already exists (persistent DB), just look it up. - match app_context.register_wallet(wallet) { - Ok((hash, _)) => { - tracing::info!("Registered framework wallet (seed_hash: {:?})", &hash[..4]); - } - Err(TaskError::WalletAlreadyImported) => { - tracing::info!("Framework wallet already registered (reusing from persistent DB)"); - } - Err(e) => panic!("Failed to register framework wallet: {}", e), - } - // Wait for wallet to appear in SPV wait::wait_for_wallet_in_spv(&app_context, framework_wallet_hash, Duration::from_secs(30)) .await .expect("Framework wallet not picked up by SPV"); // Wait for SPV to sync and funds to become spendable - tracing::info!("Waiting for SPV to sync framework wallet spendable balance..."); + // First run with a fresh SPV cache requires downloading ~54K filter + // headers + filters (from birth_height). This takes 2-5 minutes. + // Subsequent runs use cached data and complete in ~8 seconds. + let balance_timeout = std::env::var("E2E_BALANCE_TIMEOUT_SECS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(300); + tracing::info!("Waiting for SPV to sync framework wallet spendable balance (timeout: {}s)...", balance_timeout); match wait::wait_for_spendable_balance( &app_context, framework_wallet_hash, 1, // at least 1 duff spendable - Duration::from_secs(180), + Duration::from_secs(balance_timeout), ) .await { @@ -229,13 +274,22 @@ impl BackendTestContext { wallets .get(&framework_wallet_hash) .map(|w| { - let mut guard = w.write().expect("wallet lock"); + let guard = w.read().expect("wallet lock"); let bal = (guard.confirmed_balance_duffs(), guard.total_balance_duffs()); let addr = guard - .receive_address(Network::Testnet, false, Some(&app_context)) - .map(|a| a.to_string()) - .unwrap_or_else(|_| "".to_string()); + .platform_wallet + .as_ref() + .and_then(|pw| pw.try_state()) + .and_then(|info| { + info.managed_state.wallet_info().accounts.standard_bip44_accounts.get(&0) + .and_then(|a| { + let addrs = a.account_type.all_addresses(); + addrs.into_iter().next() + }) + .map(|a: dash_sdk::dpp::dashcore::Address| a.to_string()) + }) + .unwrap_or_else(|| "".to_string()); (bal.0, bal.1, addr) }) .unwrap_or((0, 0, "".to_string())) @@ -249,13 +303,15 @@ impl BackendTestContext { } } - // Wait for SPV to fully sync (including masternodes) so MempoolManager - // is active and bloom filter is built before any test broadcasts. - tracing::info!("Waiting for SPV to complete full sync (masternodes + mempool)..."); - wait::wait_for_spv_running(&app_context, Duration::from_secs(120)) + // Wait for SPV filters to sync so mempool bloom filter is active + // before any test broadcasts. We don't require masternodes to sync + // because testnet quorum rotation data can fail (QRInfo errors). + // The wallet is fully functional for transactions without masternodes. + tracing::info!("Waiting for SPV filters to sync..."); + wait::wait_for_spv_syncing_or_running(&app_context, Duration::from_secs(120)) .await - .expect("SPV did not reach Running state within 120s"); - tracing::info!("SPV fully synced — mempool bloom filter active"); + .expect("SPV did not start syncing within 120s"); + tracing::info!("SPV filters synced — ready for transactions"); // Verify balance is above minimum threshold funding::verify_framework_funded(&app_context, framework_wallet_hash).await; @@ -326,10 +382,16 @@ impl BackendTestContext { tokio::time::sleep(Duration::from_millis(200)).await; tracing::trace!(seed_hash = ?&seed_hash[..4], "create_funded_test_wallet: waited for bloom filter rebuild tick"); - // Get test wallet's receive address + // Get test wallet's receive address — extract PlatformWallet under + // short sync lock, then drop before .await to avoid deadlock. let test_address = { - let mut w = wallet_arc.write().expect("wallet lock"); - w.receive_address(Network::Testnet, false, Some(app_context)) + let pw = { + let w = wallet_arc.read().expect("wallet lock"); + w.platform_wallet.clone().expect("platform wallet must exist") + }; + pw.core() + .next_receive_address() + .await .expect("Failed to get test wallet receive address") .to_string() }; diff --git a/tests/backend-e2e/framework/identity_helpers.rs b/tests/backend-e2e/framework/identity_helpers.rs index 03021aba4..ae4e23924 100644 --- a/tests/backend-e2e/framework/identity_helpers.rs +++ b/tests/backend-e2e/framework/identity_helpers.rs @@ -77,10 +77,19 @@ pub fn build_identity_registration( } /// Get a receive address string from a wallet. -pub fn get_receive_address(app_context: &AppContext, wallet_arc: &Arc>) -> String { - let mut wallet = wallet_arc.write().expect("wallet lock"); - wallet - .receive_address(Network::Testnet, false, Some(app_context)) +/// +/// Extracts the `PlatformWallet` under a short sync lock, then drops the lock +/// before awaiting the async `next_receive_address` (which takes a tokio write +/// lock on `PlatformWalletInfo`). Holding `std::sync::RwLock` across `.await` +/// would deadlock with SPV block processing. +pub async fn get_receive_address(_app_context: &AppContext, wallet_arc: &Arc>) -> String { + let pw = { + let wallet = wallet_arc.read().expect("wallet lock"); + wallet.platform_wallet.clone().expect("platform wallet must exist") + }; + pw.core() + .next_receive_address() + .await .expect("Failed to get receive address") .to_string() } diff --git a/tests/backend-e2e/framework/wait.rs b/tests/backend-e2e/framework/wait.rs index 90cd2491b..8e7018162 100644 --- a/tests/backend-e2e/framework/wait.rs +++ b/tests/backend-e2e/framework/wait.rs @@ -138,7 +138,8 @@ pub async fn wait_for_spendable_balance( }) } -/// Wait until a wallet appears in the SPV subsystem. +/// Wait until a wallet appears in the SPV subsystem (registered with +/// PlatformWalletManager, as indicated by the wallet-ID mapping). pub async fn wait_for_wallet_in_spv( app_context: &Arc, wallet_hash: WalletSeedHash, @@ -146,8 +147,12 @@ pub async fn wait_for_wallet_in_spv( ) -> Result<(), String> { timeout(wait_timeout, async { loop { - let snapshot = app_context.spv_manager().det_wallets_snapshot(); - if snapshot.contains_key(&wallet_hash) { + let registered = app_context + .wallet_id_mapping() + .lock() + .map(|m| m.wallet_id_for_seed(&wallet_hash).is_some()) + .unwrap_or(false); + if registered { return; } tokio::time::sleep(Duration::from_millis(500)).await; @@ -157,18 +162,20 @@ pub async fn wait_for_wallet_in_spv( .map_err(|_| "Timed out waiting for wallet in SPV".to_string()) } -/// Wait for SPV to complete initial sync (all managers including masternodes). +/// Wait for SPV to reach at least Syncing state (filters active). /// -/// `SpvStatus::Running` is set after `SyncComplete` fires, which means -/// MempoolManager is activated and bloom filter is built. -pub async fn wait_for_spv_running( +/// Accepts both `Syncing` and `Running`. Does NOT require masternode +/// sync which can fail on testnet (QRInfo errors). The wallet is fully +/// functional for transactions once filters are synced. +pub async fn wait_for_spv_syncing_or_running( app_context: &Arc, wait_timeout: Duration, ) -> Result<(), String> { use dash_evo_tool::spv::SpvStatus; timeout(wait_timeout, async { loop { - if app_context.connection_status().spv_status() == SpvStatus::Running { + let status = app_context.connection_status().spv_status(); + if matches!(status, SpvStatus::Syncing | SpvStatus::Running) { return; } tokio::time::sleep(Duration::from_millis(500)).await; @@ -177,7 +184,7 @@ pub async fn wait_for_spv_running( .await .map_err(|_| { format!( - "Timed out after {:?} waiting for SPV to reach Running state", + "Timed out after {:?} waiting for SPV to reach Syncing/Running state", wait_timeout ) }) @@ -188,10 +195,10 @@ pub async fn wait_for_spv_peers( app_context: &Arc, wait_timeout: Duration, ) -> Result<(), String> { - let spv = app_context.spv_manager().clone(); + let bridge = app_context.spv_event_bridge().clone(); timeout(wait_timeout, async move { loop { - let snapshot = spv.status_async().await; + let snapshot = bridge.status(); if snapshot.connected_peers > 0 { return; } diff --git a/tests/backend-e2e/identity_withdraw.rs b/tests/backend-e2e/identity_withdraw.rs index cd9250a81..dd7f0177b 100644 --- a/tests/backend-e2e/identity_withdraw.rs +++ b/tests/backend-e2e/identity_withdraw.rs @@ -35,7 +35,7 @@ async fn test_withdraw_from_identity() { tracing::info!("Identity balance before withdrawal: {}", initial_balance); // Get a Core address to withdraw to - let withdraw_address_str = get_receive_address(&ctx.app_context, &wallet_arc); + let withdraw_address_str = get_receive_address(&ctx.app_context, &wallet_arc).await; let withdraw_address = Address::from_str(&withdraw_address_str) .expect("Valid address") .assume_checked(); diff --git a/tests/backend-e2e/send_funds.rs b/tests/backend-e2e/send_funds.rs index 2b9bad852..036dc70da 100644 --- a/tests/backend-e2e/send_funds.rs +++ b/tests/backend-e2e/send_funds.rs @@ -26,7 +26,7 @@ async fn test_send_and_receive_funds() { // Send 2,000,000 duffs from A to B let send_amount: u64 = 2_000_000; - let b_address = get_receive_address(app_context, &wallet_b); + let b_address = get_receive_address(app_context, &wallet_b).await; // Ensure wallet A has spendable balance before sending wait_for_spendable_balance(app_context, hash_a, send_amount, Duration::from_secs(120)) @@ -83,7 +83,7 @@ async fn test_send_and_receive_funds() { .expect("Wallet B funds should become spendable"); // Send funds back from B to A - let a_address = get_receive_address(app_context, &wallet_a); + let a_address = get_receive_address(app_context, &wallet_a).await; let request = WalletPaymentRequest { recipients: vec![PaymentRecipient { diff --git a/tests/backend-e2e/spv_wallet.rs b/tests/backend-e2e/spv_wallet.rs index 6012defaf..11bce68f6 100644 --- a/tests/backend-e2e/spv_wallet.rs +++ b/tests/backend-e2e/spv_wallet.rs @@ -28,10 +28,8 @@ async fn test_spv_sync_and_create_wallet() { ) .expect("Wallet::new_from_seed should succeed"); - assert!( - !wallet.known_addresses.is_empty(), - "New wallet should have at least one known address" - ); + // Addresses are managed by PlatformWallet (created during register_wallet), + // so a freshly constructed Wallet has no address fields to check. // Register the wallet let (seed_hash, _wallet_arc) = app_context @@ -59,11 +57,16 @@ async fn test_spv_sync_and_create_wallet() { ); } - // Verify in SPV (10s timeout) + // Verify in SPV (10s timeout) — wallet is registered with + // PlatformWalletManager, indicated by the wallet-ID mapping. let wallet_in_spv = timeout(Duration::from_secs(10), async { loop { - let snapshot = app_context.spv_manager().det_wallets_snapshot(); - if snapshot.contains_key(&seed_hash) { + let registered = app_context + .wallet_id_mapping() + .lock() + .map(|m| m.wallet_id_for_seed(&seed_hash).is_some()) + .unwrap_or(false); + if registered { return true; } tokio::time::sleep(Duration::from_millis(200)).await; diff --git a/tests/backend-e2e/tx_is_ours.rs b/tests/backend-e2e/tx_is_ours.rs index 1281b69d2..dc1099f7c 100644 --- a/tests/backend-e2e/tx_is_ours.rs +++ b/tests/backend-e2e/tx_is_ours.rs @@ -30,7 +30,7 @@ async fn test_spv_transactions_is_ours_flag() { 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); + let b_address = get_receive_address(app_context, &wallet_b).await; // Wait for A to have spendable funds wait_for_spendable_balance(app_context, hash_a, send_amount, Duration::from_secs(120)) @@ -93,8 +93,8 @@ async fn test_spv_transactions_is_ours_flag() { .expect("wallet A") .read() .expect("lock"); - let tx = wallet - .transactions + let transactions = wallet.get_transactions(); + let tx = transactions .iter() .find(|t| t.txid.to_string() == payment_txid) .unwrap_or_else(|| panic!("Wallet A should have tx {payment_txid}")); @@ -116,8 +116,8 @@ async fn test_spv_transactions_is_ours_flag() { .expect("wallet B") .read() .expect("lock"); - let tx = wallet - .transactions + let transactions = wallet.get_transactions(); + let tx = transactions .iter() .find(|t| t.txid.to_string() == payment_txid) .unwrap_or_else(|| panic!("Wallet B should have tx {payment_txid}"));