diff --git a/Cargo.lock b/Cargo.lock index e1bb06250..340868b27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1043,6 +1043,17 @@ dependencies = [ "digest", ] +[[package]] +name = "blake2b_simd" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79834656f71332577234b50bfc009996f7449e0c056884e6a02492ded0ca2f3" +dependencies = [ + "arrayref", + "arrayvec", + "constant_time_eq", +] + [[package]] name = "blake3" version = "1.8.3" @@ -1112,6 +1123,17 @@ dependencies = [ "piper", ] +[[package]] +name = "bls12_381" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7bc6d6292be3a19e6379786dac800f551e5865a5bb51ebbe3064ab80433f403" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "blsful" version = "3.0.0" @@ -1341,6 +1363,17 @@ dependencies = [ "libc", ] +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "chacha20" version = "0.10.0" @@ -1352,6 +1385,19 @@ dependencies = [ "rand_core 0.10.0", ] +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.44" @@ -1432,6 +1478,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common", "inout", + "zeroize", ] [[package]] @@ -1636,6 +1683,15 @@ dependencies = [ "libc", ] +[[package]] +name = "core2" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "239fa3ae9b63c2dc74bd3fa852d4792b8b305ae64eeede946265b6af62f1fff3" +dependencies = [ + "memchr", +] + [[package]] name = "core2" version = "0.4.0" @@ -1818,7 +1874,7 @@ dependencies = [ [[package]] name = "dapi-grpc" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=93556aa4b#93556aa4b97fa024890926e315c1061181c8b663" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" dependencies = [ "dash-platform-macros", "futures-core", @@ -1920,7 +1976,7 @@ dependencies = [ [[package]] name = "dash-context-provider" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=93556aa4b#93556aa4b97fa024890926e315c1061181c8b663" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" dependencies = [ "dpp", "drive", @@ -1999,6 +2055,7 @@ dependencies = [ "winres", "zeroize", "zeromq", + "zip32", "zmq", "zxcvbn", ] @@ -2006,7 +2063,7 @@ dependencies = [ [[package]] name = "dash-platform-macros" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=93556aa4b#93556aa4b97fa024890926e315c1061181c8b663" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" dependencies = [ "heck", "quote", @@ -2016,7 +2073,7 @@ dependencies = [ [[package]] name = "dash-sdk" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=93556aa4b#93556aa4b97fa024890926e315c1061181c8b663" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" dependencies = [ "arc-swap", "async-trait", @@ -2033,6 +2090,7 @@ dependencies = [ "drive-proof-verifier", "envy", "futures", + "grovedb-commitment-tree", "hex", "http", "js-sys", @@ -2051,7 +2109,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=450f72f#450f72f5148b9fea2cd79bd1e37fa9d68a9fd18a" +source = "git+https://github.com/dashpay/rust-dashcore?branch=feat%2Fmempool-support#450f72f5148b9fea2cd79bd1e37fa9d68a9fd18a" dependencies = [ "anyhow", "async-trait", @@ -2059,8 +2117,8 @@ dependencies = [ "blsful", "chrono", "clap", - "dashcore 0.42.0 (git+https://github.com/dashpay/rust-dashcore?rev=450f72f)", - "dashcore_hashes 0.42.0 (git+https://github.com/dashpay/rust-dashcore?rev=450f72f)", + "dashcore", + "dashcore_hashes", "futures", "hex", "hickory-resolver", @@ -2083,7 +2141,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=450f72f#450f72f5148b9fea2cd79bd1e37fa9d68a9fd18a" +source = "git+https://github.com/dashpay/rust-dashcore?branch=feat%2Fmempool-support#450f72f5148b9fea2cd79bd1e37fa9d68a9fd18a" dependencies = [ "anyhow", "base64-compat", @@ -2093,8 +2151,8 @@ dependencies = [ "bitvec", "blake3", "blsful", - "dashcore-private 0.42.0 (git+https://github.com/dashpay/rust-dashcore?rev=450f72f)", - "dashcore_hashes 0.42.0 (git+https://github.com/dashpay/rust-dashcore?rev=450f72f)", + "dashcore-private", + "dashcore_hashes", "ed25519-dalek", "hex", "hex_lit", @@ -2105,41 +2163,15 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "dashcore" -version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=9959201593826def0ad1f6db51b2ceb95b68a1ca#9959201593826def0ad1f6db51b2ceb95b68a1ca" -dependencies = [ - "anyhow", - "bech32 0.9.1", - "bincode 2.0.1", - "bincode_derive", - "bitvec", - "blake3", - "dashcore-private 0.42.0 (git+https://github.com/dashpay/rust-dashcore?rev=9959201593826def0ad1f6db51b2ceb95b68a1ca)", - "dashcore_hashes 0.42.0 (git+https://github.com/dashpay/rust-dashcore?rev=9959201593826def0ad1f6db51b2ceb95b68a1ca)", - "hex", - "hex_lit", - "log", - "rustversion", - "secp256k1", - "thiserror 2.0.18", -] - [[package]] name = "dashcore-private" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=450f72f#450f72f5148b9fea2cd79bd1e37fa9d68a9fd18a" - -[[package]] -name = "dashcore-private" -version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=9959201593826def0ad1f6db51b2ceb95b68a1ca#9959201593826def0ad1f6db51b2ceb95b68a1ca" +source = "git+https://github.com/dashpay/rust-dashcore?branch=feat%2Fmempool-support#450f72f5148b9fea2cd79bd1e37fa9d68a9fd18a" [[package]] name = "dashcore-rpc" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=450f72f#450f72f5148b9fea2cd79bd1e37fa9d68a9fd18a" +source = "git+https://github.com/dashpay/rust-dashcore?branch=feat%2Fmempool-support#450f72f5148b9fea2cd79bd1e37fa9d68a9fd18a" dependencies = [ "dashcore-rpc-json", "hex", @@ -2152,10 +2184,10 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=450f72f#450f72f5148b9fea2cd79bd1e37fa9d68a9fd18a" +source = "git+https://github.com/dashpay/rust-dashcore?branch=feat%2Fmempool-support#450f72f5148b9fea2cd79bd1e37fa9d68a9fd18a" dependencies = [ "bincode 2.0.1", - "dashcore 0.42.0 (git+https://github.com/dashpay/rust-dashcore?rev=450f72f)", + "dashcore", "hex", "key-wallet", "serde", @@ -2167,25 +2199,15 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=450f72f#450f72f5148b9fea2cd79bd1e37fa9d68a9fd18a" +source = "git+https://github.com/dashpay/rust-dashcore?branch=feat%2Fmempool-support#450f72f5148b9fea2cd79bd1e37fa9d68a9fd18a" dependencies = [ "bincode 2.0.1", - "dashcore-private 0.42.0 (git+https://github.com/dashpay/rust-dashcore?rev=450f72f)", + "dashcore-private", "rs-x11-hash", "secp256k1", "serde", ] -[[package]] -name = "dashcore_hashes" -version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=9959201593826def0ad1f6db51b2ceb95b68a1ca#9959201593826def0ad1f6db51b2ceb95b68a1ca" -dependencies = [ - "bincode 2.0.1", - "dashcore-private 0.42.0 (git+https://github.com/dashpay/rust-dashcore?rev=9959201593826def0ad1f6db51b2ceb95b68a1ca)", - "secp256k1", -] - [[package]] name = "dashmap" version = "5.5.3" @@ -2202,7 +2224,7 @@ dependencies = [ [[package]] name = "dashpay-contract" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=93556aa4b#93556aa4b97fa024890926e315c1061181c8b663" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" dependencies = [ "platform-value", "platform-version", @@ -2213,7 +2235,7 @@ dependencies = [ [[package]] name = "data-contracts" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=93556aa4b#93556aa4b97fa024890926e315c1061181c8b663" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" dependencies = [ "dashpay-contract", "dpns-contract", @@ -2466,7 +2488,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "dpns-contract" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=93556aa4b#93556aa4b97fa024890926e315c1061181c8b663" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" dependencies = [ "platform-value", "platform-version", @@ -2477,7 +2499,7 @@ dependencies = [ [[package]] name = "dpp" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=93556aa4b#93556aa4b97fa024890926e315c1061181c8b663" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" dependencies = [ "anyhow", "async-trait", @@ -2490,13 +2512,14 @@ dependencies = [ "chrono-tz", "ciborium", "dash-spv", - "dashcore 0.42.0 (git+https://github.com/dashpay/rust-dashcore?rev=450f72f)", + "dashcore", "dashcore-rpc", "data-contracts", "derive_more 1.0.0", "dpp-json-convertible-derive", "env_logger", "getrandom 0.2.17", + "grovedb-commitment-tree", "hex", "indexmap 2.13.0", "integer-encoding", @@ -2525,7 +2548,7 @@ dependencies = [ [[package]] name = "dpp-json-convertible-derive" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=93556aa4b#93556aa4b97fa024890926e315c1061181c8b663" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" dependencies = [ "proc-macro2", "quote", @@ -2535,17 +2558,17 @@ dependencies = [ [[package]] name = "drive" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=93556aa4b#93556aa4b97fa024890926e315c1061181c8b663" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" dependencies = [ "bincode 2.0.1", "byteorder", "derive_more 1.0.0", "dpp", - "grovedb 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", - "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", + "grovedb 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=dd99ed1db0350e5f39127573808dd172c6bc2346)", - "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", + "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", @@ -2560,7 +2583,7 @@ dependencies = [ [[package]] name = "drive-proof-verifier" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=93556aa4b#93556aa4b97fa024890926e315c1061181c8b663" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" dependencies = [ "bincode 2.0.1", "dapi-grpc", @@ -3150,7 +3173,7 @@ dependencies = [ [[package]] name = "feature-flags-contract" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=93556aa4b#93556aa4b97fa024890926e315c1061181c8b663" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" dependencies = [ "platform-value", "platform-version", @@ -3302,6 +3325,20 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8866fac38f53fc87fa3ae1b09ddd723e0482f8fa74323518b4c59df2c55a00a" +[[package]] +name = "fpe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26c4b37de5ae15812a764c958297cfc50f5c010438f60c6ce75d11b802abd404" +dependencies = [ + "cbc", + "cipher", + "libm", + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -3488,6 +3525,18 @@ dependencies = [ "wasip3", ] +[[package]] +name = "getset" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "ghash" version = "0.5.1" @@ -3673,6 +3722,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", + "memuse", "rand 0.8.5", "rand_core 0.6.4", "rand_xorshift", @@ -3704,20 +3754,20 @@ dependencies = [ [[package]] name = "grovedb" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346#dd99ed1db0350e5f39127573808dd172c6bc2346" +source = "git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b#8f25b20d04bfc0e8bdfb3870676d647a0d74918b" dependencies = [ "bincode 2.0.1", "bincode_derive", "blake3", "grovedb-bulk-append-tree", - "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", + "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", "grovedb-dense-fixed-sized-merkle-tree", - "grovedb-element 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", - "grovedb-merk 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", + "grovedb-element 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", + "grovedb-merk 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", "grovedb-merkle-mountain-range", - "grovedb-path 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", + "grovedb-path 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", "grovedb-query", - "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", + "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", "hex", "hex-literal", "indexmap 2.13.0", @@ -3730,11 +3780,11 @@ dependencies = [ [[package]] name = "grovedb-bulk-append-tree" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346#dd99ed1db0350e5f39127573808dd172c6bc2346" +source = "git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b#8f25b20d04bfc0e8bdfb3870676d647a0d74918b" dependencies = [ "bincode 2.0.1", "blake3", - "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", + "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", "grovedb-dense-fixed-sized-merkle-tree", "grovedb-merkle-mountain-range", "grovedb-query", @@ -3742,6 +3792,21 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "grovedb-commitment-tree" +version = "4.0.0" +source = "git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b#8f25b20d04bfc0e8bdfb3870676d647a0d74918b" +dependencies = [ + "blake3", + "grovedb-bulk-append-tree", + "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", + "incrementalmerkletree", + "orchard", + "rusqlite", + "shardtree", + "thiserror 2.0.18", +] + [[package]] name = "grovedb-costs" version = "4.0.0" @@ -3755,7 +3820,7 @@ dependencies = [ [[package]] name = "grovedb-costs" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346#dd99ed1db0350e5f39127573808dd172c6bc2346" +source = "git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b#8f25b20d04bfc0e8bdfb3870676d647a0d74918b" dependencies = [ "integer-encoding", "intmap", @@ -3765,11 +3830,11 @@ dependencies = [ [[package]] name = "grovedb-dense-fixed-sized-merkle-tree" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346#dd99ed1db0350e5f39127573808dd172c6bc2346" +source = "git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b#8f25b20d04bfc0e8bdfb3870676d647a0d74918b" dependencies = [ "bincode 2.0.1", "blake3", - "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", + "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", "grovedb-query", "thiserror 2.0.18", ] @@ -3791,12 +3856,12 @@ dependencies = [ [[package]] name = "grovedb-element" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346#dd99ed1db0350e5f39127573808dd172c6bc2346" +source = "git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b#8f25b20d04bfc0e8bdfb3870676d647a0d74918b" dependencies = [ "bincode 2.0.1", "bincode_derive", - "grovedb-path 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", - "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", + "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", "integer-encoding", "thiserror 2.0.18", @@ -3805,9 +3870,9 @@ dependencies = [ [[package]] name = "grovedb-epoch-based-storage-flags" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346#dd99ed1db0350e5f39127573808dd172c6bc2346" +source = "git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b#8f25b20d04bfc0e8bdfb3870676d647a0d74918b" dependencies = [ - "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", + "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", "hex", "integer-encoding", "intmap", @@ -3838,19 +3903,19 @@ dependencies = [ [[package]] name = "grovedb-merk" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346#dd99ed1db0350e5f39127573808dd172c6bc2346" +source = "git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b#8f25b20d04bfc0e8bdfb3870676d647a0d74918b" dependencies = [ "bincode 2.0.1", "bincode_derive", "blake3", "byteorder", "ed", - "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", - "grovedb-element 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", - "grovedb-path 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", + "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", + "grovedb-element 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", + "grovedb-path 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", "grovedb-query", - "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", - "grovedb-visualize 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", + "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", + "grovedb-visualize 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", "hex", "indexmap 2.13.0", "integer-encoding", @@ -3860,11 +3925,11 @@ dependencies = [ [[package]] name = "grovedb-merkle-mountain-range" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346#dd99ed1db0350e5f39127573808dd172c6bc2346" +source = "git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b#8f25b20d04bfc0e8bdfb3870676d647a0d74918b" dependencies = [ "bincode 2.0.1", "blake3", - "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", + "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", ] [[package]] @@ -3878,7 +3943,7 @@ dependencies = [ [[package]] name = "grovedb-path" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346#dd99ed1db0350e5f39127573808dd172c6bc2346" +source = "git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b#8f25b20d04bfc0e8bdfb3870676d647a0d74918b" dependencies = [ "hex", ] @@ -3886,7 +3951,7 @@ dependencies = [ [[package]] name = "grovedb-query" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346#dd99ed1db0350e5f39127573808dd172c6bc2346" +source = "git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b#8f25b20d04bfc0e8bdfb3870676d647a0d74918b" dependencies = [ "bincode 2.0.1", "byteorder", @@ -3909,7 +3974,7 @@ dependencies = [ [[package]] name = "grovedb-version" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346#dd99ed1db0350e5f39127573808dd172c6bc2346" +source = "git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b#8f25b20d04bfc0e8bdfb3870676d647a0d74918b" dependencies = [ "thiserror 2.0.18", "versioned-feature-core 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)", @@ -3927,7 +3992,7 @@ dependencies = [ [[package]] name = "grovedb-visualize" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346#dd99ed1db0350e5f39127573808dd172c6bc2346" +source = "git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b#8f25b20d04bfc0e8bdfb3870676d647a0d74918b" dependencies = [ "hex", "itertools 0.14.0", @@ -4001,6 +4066,61 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "halo2_gadgets" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45824ce0dd12e91ec0c68ebae2a7ed8ae19b70946624c849add59f1d1a62a143" +dependencies = [ + "arrayvec", + "bitvec", + "ff", + "group", + "halo2_poseidon", + "halo2_proofs", + "lazy_static", + "pasta_curves", + "rand 0.8.5", + "sinsemilla", + "subtle", + "uint", +] + +[[package]] +name = "halo2_legacy_pdqsort" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47716fe1ae67969c5e0b2ef826f32db8c3be72be325e1aa3c1951d06b5575ec5" + +[[package]] +name = "halo2_poseidon" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa3da60b81f02f9b33ebc6252d766f843291fb4d2247a07ae73d20b791fc56f" +dependencies = [ + "bitvec", + "ff", + "group", + "pasta_curves", +] + +[[package]] +name = "halo2_proofs" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05713f117155643ce10975e0bee44a274bcda2f4bb5ef29a999ad67c1fa8d4d3" +dependencies = [ + "blake2b_simd", + "ff", + "group", + "halo2_legacy_pdqsort", + "indexmap 1.9.3", + "maybe-rayon", + "pasta_curves", + "rand_core 0.6.4", + "tracing", +] + [[package]] name = "hash32" version = "0.3.1" @@ -4506,6 +4626,15 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" +[[package]] +name = "incrementalmerkletree" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30821f91f0fa8660edca547918dc59812893b497d07c1144f326f07fdd94aba9" +dependencies = [ + "either", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -4750,6 +4879,20 @@ dependencies = [ "serde_json", ] +[[package]] +name = "jubjub" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8499f7a74008aafbecb2a2e608a3e13e4dd3e84df198b604451efe93f2de6e61" +dependencies = [ + "bitvec", + "bls12_381", + "ff", + "group", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "jwalk" version = "0.8.1" @@ -4772,15 +4915,15 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=450f72f#450f72f5148b9fea2cd79bd1e37fa9d68a9fd18a" +source = "git+https://github.com/dashpay/rust-dashcore?branch=feat%2Fmempool-support#450f72f5148b9fea2cd79bd1e37fa9d68a9fd18a" dependencies = [ "async-trait", "base58ck", "bip39", "bitflags 2.11.0", - "dashcore 0.42.0 (git+https://github.com/dashpay/rust-dashcore?rev=450f72f)", - "dashcore-private 0.42.0 (git+https://github.com/dashpay/rust-dashcore?rev=450f72f)", - "dashcore_hashes 0.42.0 (git+https://github.com/dashpay/rust-dashcore?rev=450f72f)", + "dashcore", + "dashcore-private", + "dashcore_hashes", "getrandom 0.2.17", "hex", "rand 0.8.5", @@ -4797,7 +4940,7 @@ dependencies = [ [[package]] name = "keyword-search-contract" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=93556aa4b#93556aa4b97fa024890926e315c1061181c8b663" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" dependencies = [ "platform-value", "platform-version", @@ -4858,6 +5001,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin 0.9.8", +] [[package]] name = "lazycell" @@ -5000,7 +5146,7 @@ dependencies = [ [[package]] name = "masternode-reward-shares-contract" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=93556aa4b#93556aa4b97fa024890926e315c1061181c8b663" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" dependencies = [ "platform-value", "platform-version", @@ -5023,6 +5169,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + [[package]] name = "memchr" version = "2.8.0" @@ -5047,6 +5203,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "memuse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d97bbf43eb4f088f8ca469930cde17fa036207c9a5e02ccc5107c4e8b17c964" + [[package]] name = "merlin" version = "3.0.0" @@ -5306,6 +5468,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "nonempty" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "549e471b99ccaf2f89101bec68f4d244457d5a95a9c3d0672e9564124397741d" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -5827,6 +5995,41 @@ dependencies = [ "libredox", ] +[[package]] +name = "orchard" +version = "0.12.0" +source = "git+https://github.com/dashpay/orchard.git?rev=41c8f7169f2683c99cf0e0c63e8d25ec12c47a79#41c8f7169f2683c99cf0e0c63e8d25ec12c47a79" +dependencies = [ + "aes", + "bitvec", + "blake2b_simd", + "core2 0.3.3", + "ff", + "fpe", + "getset", + "group", + "halo2_gadgets", + "halo2_poseidon", + "halo2_proofs", + "hex", + "incrementalmerkletree", + "lazy_static", + "memuse", + "nonempty", + "pasta_curves", + "rand 0.8.5", + "rand_core 0.6.4", + "reddsa", + "serde", + "sinsemilla", + "subtle", + "tracing", + "visibility", + "zcash_note_encryption", + "zcash_spec", + "zip32", +] + [[package]] name = "ordered-float" version = "5.1.0" @@ -5913,6 +6116,21 @@ dependencies = [ "subtle", ] +[[package]] +name = "pasta_curves" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e57598f73cc7e1b2ac63c79c517b31a0877cd7c402cdcaa311b5208de7a095" +dependencies = [ + "blake2b_simd", + "ff", + "group", + "lazy_static", + "rand 0.8.5", + "static_assertions", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -6076,19 +6294,18 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "platform-encryption" version = "2.1.1" -source = "git+https://github.com/dashpay/platform?rev=93556aa4b#93556aa4b97fa024890926e315c1061181c8b663" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" dependencies = [ "aes", "cbc", - "dashcore 0.42.0 (git+https://github.com/dashpay/rust-dashcore?rev=9959201593826def0ad1f6db51b2ceb95b68a1ca)", - "sha2", + "dashcore", "thiserror 1.0.69", ] [[package]] name = "platform-serialization" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=93556aa4b#93556aa4b97fa024890926e315c1061181c8b663" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" dependencies = [ "bincode 2.0.1", "platform-version", @@ -6097,7 +6314,7 @@ dependencies = [ [[package]] name = "platform-serialization-derive" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=93556aa4b#93556aa4b97fa024890926e315c1061181c8b663" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" dependencies = [ "proc-macro2", "quote", @@ -6108,7 +6325,7 @@ dependencies = [ [[package]] name = "platform-value" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=93556aa4b#93556aa4b97fa024890926e315c1061181c8b663" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" dependencies = [ "base64 0.22.1", "bincode 2.0.1", @@ -6128,10 +6345,10 @@ dependencies = [ [[package]] name = "platform-version" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=93556aa4b#93556aa4b97fa024890926e315c1061181c8b663" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" dependencies = [ "bincode 2.0.1", - "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=dd99ed1db0350e5f39127573808dd172c6bc2346)", + "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)", ] @@ -6139,7 +6356,7 @@ dependencies = [ [[package]] name = "platform-versioning" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=93556aa4b#93556aa4b97fa024890926e315c1061181c8b663" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" dependencies = [ "proc-macro2", "quote", @@ -6192,6 +6409,17 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "polyval" version = "0.6.2" @@ -6278,6 +6506,28 @@ dependencies = [ "toml_edit 0.25.5+spec-1.1.0", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -6516,7 +6766,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" dependencies = [ - "chacha20", + "chacha20 0.10.0", "getrandom 0.4.2", "rand_core 0.10.0", ] @@ -6615,6 +6865,24 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "reddsa" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78a5191930e84973293aa5f532b513404460cd2216c1cfb76d08748c15b40b02" +dependencies = [ + "blake2b_simd", + "byteorder", + "group", + "hex", + "jubjub", + "pasta_curves", + "rand_core 0.6.4", + "serde", + "thiserror 1.0.69", + "zeroize", +] + [[package]] name = "redox_syscall" version = "0.4.1" @@ -6954,7 +7222,7 @@ dependencies = [ [[package]] name = "rs-dapi-client" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=93556aa4b#93556aa4b97fa024890926e315c1061181c8b663" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" dependencies = [ "backon", "chrono", @@ -7532,6 +7800,18 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shardtree" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "359e552886ae54d1642091645980d83f7db465fd9b5b0248e3680713c1773388" +dependencies = [ + "bitflags 2.11.0", + "either", + "incrementalmerkletree", + "tracing", +] + [[package]] name = "shlex" version = "1.3.0" @@ -7588,6 +7868,17 @@ dependencies = [ "log", ] +[[package]] +name = "sinsemilla" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d268ae0ea06faafe1662e9967cd4f9022014f5eeb798e0c302c876df8b7af9c" +dependencies = [ + "group", + "pasta_curves", + "subtle", +] + [[package]] name = "siphasher" version = "1.0.2" @@ -7707,6 +7998,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "spin" version = "0.10.0" @@ -7786,7 +8083,7 @@ checksum = "227c4f8561598188d0df96dbe749824576174bba278b5b6bb2eacff1066067d0" dependencies = [ "hashbrown 0.16.1", "rustversion", - "spin", + "spin 0.10.0", ] [[package]] @@ -8171,7 +8468,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "token-history-contract" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=93556aa4b#93556aa4b97fa024890926e315c1061181c8b663" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" dependencies = [ "platform-value", "platform-version", @@ -8636,13 +8933,25 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "uint" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f64bba2c53b04fcab63c01a7d7427eadc821e3bc48c34dc9ba29c501164b52" +dependencies = [ + "byteorder", + "crunchy", + "hex", + "static_assertions", +] + [[package]] name = "uint-zigzag" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "abbf77aed65cb885a8ba07138c365879be3d9a93dce82bf6cc50feca9138ec15" dependencies = [ - "core2", + "core2 0.4.0", ] [[package]] @@ -8916,6 +9225,17 @@ version = "0.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" +[[package]] +name = "visibility" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "vsss-rs" version = "5.1.0" @@ -8947,7 +9267,7 @@ dependencies = [ [[package]] name = "wallet-utils-contract" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=93556aa4b#93556aa4b97fa024890926e315c1061181c8b663" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" dependencies = [ "platform-value", "platform-version", @@ -10361,7 +10681,7 @@ dependencies = [ [[package]] name = "withdrawals-contract" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=93556aa4b#93556aa4b97fa024890926e315c1061181c8b663" +source = "git+https://github.com/dashpay/platform?branch=feat%2Fmempool-support#19f9f69bfb3a3edd5304c96363af6c0f630383cf" dependencies = [ "num_enum 0.5.11", "platform-value", @@ -10576,6 +10896,27 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zcash_note_encryption" +version = "0.4.1" +source = "git+https://github.com/dashpay/zcash_note_encryption?rev=9f7e93d#9f7e93d42cef839d02b9d75918117941d453f8cb" +dependencies = [ + "chacha20 0.9.1", + "chacha20poly1305", + "cipher", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "zcash_spec" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ded3f58b93486aa79b85acba1001f5298f27a46489859934954d262533ee2915" +dependencies = [ + "blake2b_simd", +] + [[package]] name = "zerocopy" version = "0.8.47" @@ -10722,6 +11063,19 @@ dependencies = [ "zopfli", ] +[[package]] +name = "zip32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b64bf5186a8916f7a48f2a98ef599bf9c099e2458b36b819e393db1c0e768c4b" +dependencies = [ + "bech32 0.11.1", + "blake2b_simd", + "memuse", + "subtle", + "zcash_spec", +] + [[package]] name = "zlib-rs" version = "0.6.3" diff --git a/Cargo.toml b/Cargo.toml index b4987c475..8cf6cf161 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 = "93556aa4b", features = [ +dash-sdk = { git = "https://github.com/dashpay/platform", branch = "feat/mempool-support", features = [ "core_key_wallet", "core_key_wallet_manager", "core_bincode", @@ -26,7 +26,9 @@ dash-sdk = { git = "https://github.com/dashpay/platform", rev = "93556aa4b", fea "core_verification", "core_rpc_client", "core_spv", + "shielded", ] } +zip32 = "0.2.0" grovestark = { git = "https://www.github.com/dashpay/grovestark", rev = "5b9e289cca54c79b1305d5f4f40bf1148f1eb0e3" } rayon = "1.8" thiserror = "2.0.18" @@ -120,6 +122,7 @@ debug = "line-tables-only" level = "warn" check-cfg = ["cfg(tokio_unstable)", "cfg(feature, values(\"testing\", \"mcp\", \"cli\", \"headless\"))"] + [lints.clippy] uninlined_format_args = "allow" diff --git a/src/app.rs b/src/app.rs index 38440dd13..5ae8f9c02 100644 --- a/src/app.rs +++ b/src/app.rs @@ -818,6 +818,13 @@ impl AppState { } } + // Warm up the Halo 2 ProvingKey in a background thread (~30s build). + // This ensures the key is ready for the user's first shielded operation. + std::thread::spawn(|| { + let _ = crate::context::shielded::get_proving_key(); + tracing::info!("Halo 2 ProvingKey built and cached"); + }); + Ok(app_state) } @@ -887,15 +894,21 @@ impl AppState { ); } - // Handle the backend task and send the result through the channel + // Handle the backend task and send the result through the channel. + // + // Uses spawn_blocking + block_on to avoid Send bound issues with platform + // SDK types (DataContract/Sdk references across await points). fn handle_backend_task(&self, task: BackendTask) { let sender = self.task_result_sender.clone(); let app_context = self.current_app_context().clone(); - tokio::spawn(async move { - let result = app_context.run_backend_task(task, sender.clone()).await; - if let Err(e) = sender.send(result.into()).await { - tracing::error!("Failed to send task result: {}", e); - } + let handle = tokio::runtime::Handle::current(); + tokio::task::spawn_blocking(move || { + handle.block_on(async move { + let result = app_context.run_backend_task(task, sender.clone()).await; + if let Err(e) = sender.send(result.into()).await { + tracing::error!("Failed to send task result: {}", e); + } + }); }); } @@ -903,26 +916,29 @@ impl AppState { fn handle_backend_tasks(&self, tasks: Vec, mode: BackendTasksExecutionMode) { let sender = self.task_result_sender.clone(); let app_context = self.current_app_context().clone(); + let handle = tokio::runtime::Handle::current(); + + tokio::task::spawn_blocking(move || { + handle.block_on(async move { + let results = match mode { + BackendTasksExecutionMode::Sequential => { + app_context + .run_backend_tasks_sequential(tasks, sender.clone()) + .await + } + BackendTasksExecutionMode::Concurrent => { + app_context + .run_backend_tasks_concurrent(tasks, sender.clone()) + .await + } + }; - tokio::spawn(async move { - let results = match mode { - BackendTasksExecutionMode::Sequential => { - app_context - .run_backend_tasks_sequential(tasks, sender.clone()) - .await - } - BackendTasksExecutionMode::Concurrent => { - app_context - .run_backend_tasks_concurrent(tasks, sender.clone()) - .await - } - }; - - for result in results { - if let Err(e) = sender.send(result.into()).await { - tracing::error!("Failed to send task result: {}", e); + for result in results { + if let Err(e) = sender.send(result.into()).await { + tracing::error!("Failed to send task result: {}", e); + } } - } + }); }); } diff --git a/src/backend_task/core/mod.rs b/src/backend_task/core/mod.rs index 188174189..d93209f07 100644 --- a/src/backend_task/core/mod.rs +++ b/src/backend_task/core/mod.rs @@ -24,13 +24,13 @@ use dash_sdk::dpp::dashcore::{ }; use dash_sdk::dpp::fee::Credits; use dash_sdk::dpp::key_wallet::Network as WalletNetwork; +use dash_sdk::dpp::key_wallet::account::ECDSAAddressDerivation; use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::coin_selection::SelectionStrategy; use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::fee::FeeRate; use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::transaction_builder::{ BuilderError, TransactionBuilder, }; -use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use dash_sdk::dpp::key_wallet_manager::manager::{WalletError, WalletId, WalletManager}; use std::path::PathBuf; @@ -656,7 +656,7 @@ impl AppContext { ) -> Result { const FALLBACK_STEP: u64 = 100; - let network = self.wallet_network_key(); + let _network = self.wallet_network_key(); let current_height = self .spv_manager() .status() @@ -672,53 +672,83 @@ impl AppContext { .ok_or_else(|| TaskError::WalletPaymentFailed { detail: "Cannot build transaction: SPV sync height is not yet known".to_string(), })?; - const MAX_FEE_ITERATIONS: usize = 50; let total_amount: u64 = recipients.iter().map(|(_, amt)| *amt).sum(); let mut scale_factor = 1.0f64; let mut attempted_fallback = false; - // Obtain change address once before the retry loop to avoid marking - // multiple addresses as used on failed fee-adjustment attempts. - let change_result = wm - .get_change_address( - wallet_id, - DEFAULT_BIP44_ACCOUNT_INDEX, - AccountTypePreference::BIP44, - true, - ) - .map_err(|e| TaskError::WalletPaymentFailed { - detail: format!("Failed to get change address: {e}"), - })?; - let change_address = - change_result - .address + // 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 + .get(&DEFAULT_BIP44_ACCOUNT_INDEX) .ok_or_else(|| TaskError::WalletPaymentFailed { - detail: "No change address generated".to_string(), + detail: "BIP44 account missing".to_string(), })?; - for _ in 0..MAX_FEE_ITERATIONS { + let utxos: Vec<_> = account.utxos.values().cloned().collect(); + let change_index = account.get_next_change_address_index().unwrap_or(0); + (utxos, change_index) + }; + + let wallet = wm + .get_wallet(wallet_id) + .ok_or_else(|| TaskError::WalletPaymentFailed { + detail: "Wallet object not found".to_string(), + })?; + let wallet_account = wallet + .accounts + .standard_bip44_accounts + .get(&DEFAULT_BIP44_ACCOUNT_INDEX) + .ok_or_else(|| TaskError::WalletPaymentFailed { + detail: "BIP44 wallet account missing".to_string(), + })?; + let change_addr = wallet_account + .derive_change_address(change_index) + .map_err(|e| TaskError::WalletPaymentFailed { + detail: format!("Failed to derive change address: {e}"), + })?; + + loop { let scaled_recipients: Vec<(Address, u64)> = recipients .iter() .map(|(addr, amt)| (addr.clone(), (*amt as f64 * scale_factor) as u64)) .collect(); - match Self::build_unsigned_payment_tx( - wm, - wallet_id, - DEFAULT_BIP44_ACCOUNT_INDEX, - scaled_recipients, - current_height, - &change_address, - ) { + let build_result = (|| -> Result { + let mut builder = TransactionBuilder::new() + .set_fee_rate(FeeRate::normal()) + .set_change_address(change_addr.clone()); + + for (addr, amt) in &scaled_recipients { + builder = builder.add_output(addr, *amt)?; + } + + builder = builder.select_inputs( + &utxos, + SelectionStrategy::LargestFirst, + current_height, + |_| None, // No private keys for unsigned tx + )?; + + builder.build() + })(); + + match build_result { Ok(tx) => return Ok(tx), - Err(WalletError::InsufficientFunds) if request.subtract_fee_from_amount => { + Err(BuilderError::InsufficientFunds { .. }) if request.subtract_fee_from_amount => { let next_scale = if !attempted_fallback { attempted_fallback = true; let fallback_amount = self.estimate_fallback_amount( wm, wallet_id, - network, + _network, DEFAULT_BIP44_ACCOUNT_INDEX, current_height, )?; @@ -743,10 +773,6 @@ impl AppContext { } } } - - Err(TaskError::WalletPaymentFailed { - detail: "Could not build transaction after maximum fee adjustment attempts".to_string(), - }) } fn estimate_fallback_amount( @@ -791,6 +817,7 @@ impl AppContext { } /// Build an unsigned payment transaction using TransactionBuilder. + #[allow(dead_code)] fn build_unsigned_payment_tx( wm: &mut WalletManager, wallet_id: &WalletId, diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index df39cd67b..f59e3d700 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -729,6 +729,66 @@ pub enum TaskError { /// Wallet key derivation failed during construction. #[error("Could not create the wallet. Key derivation failed — please try again.")] WalletKeyDerivationFailed { detail: String }, + + // ────────────────────────────────────────────────────────────────────────── + // Shielded pool errors + // ────────────────────────────────────────────────────────────────────────── + /// No unspent shielded notes are available. + #[error("You have no shielded funds available. Please shield some credits first.")] + ShieldedNoUnspentNotes, + + /// Insufficient shielded balance to cover the requested amount. + #[error( + "Insufficient shielded balance: you have {available} credits but need {required}. Please shield more credits." + )] + ShieldedInsufficientBalance { available: u64, required: u64 }, + + /// The platform address was not found in the wallet's platform address info. + #[error("The platform address could not be found in your wallet. Please refresh and retry.")] + PlatformAddressNotFound, + + /// A Merkle witness could not be obtained for a shielded note. + #[error("Could not prepare the shielded transaction. Please sync your notes and retry.")] + ShieldedMerkleWitnessUnavailable { detail: String }, + + /// Failed to build a shielded state transition (shield, transfer, unshield, withdrawal). + #[error("Could not build the shielded transaction. Please retry.")] + ShieldedTransitionBuildFailed { detail: String }, + + /// Failed to broadcast a shielded state transition. + #[error( + "Could not broadcast the shielded transaction. Please check your connection and retry." + )] + ShieldedBroadcastFailed { + #[source] + source: Box, + }, + + /// Invalid recipient address for shielded transfer. + #[error("The recipient shielded address is invalid. Please check the address and retry.")] + ShieldedInvalidRecipientAddress, + + /// Timed out waiting for asset lock proof during shield-from-asset-lock. + #[error( + "The funding transaction was not confirmed within 5 minutes. Please check your network connection and retry." + )] + ShieldedAssetLockTimeout, + + /// Failed to sync shielded notes from the platform. + #[error( + "Could not sync shielded notes from the platform. Please check your connection and retry." + )] + ShieldedSyncFailed { detail: String }, + + /// Failed to append or checkpoint the shielded commitment tree. + #[error( + "Could not update the local shielded data. Please check available disk space and retry." + )] + ShieldedTreeUpdateFailed { detail: String }, + + /// Nullifier sync failed. + #[error("Could not check for spent shielded notes. Please check your connection and retry.")] + ShieldedNullifierSyncFailed { detail: String }, } /// Returns `true` when a `dashcore_rpc::Error` wraps an HTTP 401 response, @@ -882,10 +942,6 @@ impl From for TaskError { _ => None, }) .or_else(|| { - // Message-based fallback: when the SDK doesn't provide a structured - // consensus cause, inspect the broadcast message text. This handles - // DAPI responses where cause=None but message carries the - // duplicate-key signal — the exact regression guarded by issue #714. if let SdkError::StateTransitionBroadcastError(broadcast_err) = &error && broadcast_err.cause.is_none() { diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index f7c972b64..0f47c8628 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -38,6 +38,7 @@ use dash_sdk::query_types::{Documents, IndexMap}; use futures::future::join_all; use std::collections::BTreeMap; use std::sync::Arc; +use shielded::ShieldedTask; use tokens::TokenTask; use grovestark::GroveSTARKTask; @@ -53,6 +54,7 @@ pub mod identity; pub mod mnlist; pub mod platform_info; pub mod register_contract; +pub mod shielded; pub mod system_task; pub mod tokens; pub mod update_data_contract; @@ -94,6 +96,7 @@ pub enum BackendTask { PlatformInfo(PlatformInfoTaskRequestType), GroveSTARKTask(GroveSTARKTask), WalletTask(WalletTask), + ShieldedTask(ShieldedTask), None, } @@ -291,6 +294,42 @@ pub enum BackendTaskSuccessResult { // Core wallet list (async fetch of loaded Core wallets) CoreWalletsList(Vec), + + // Shielded pool results + ShieldedInitialized { + seed_hash: WalletSeedHash, + balance: u64, + }, + ShieldedNotesSynced { + seed_hash: WalletSeedHash, + new_notes: u32, + balance: u64, + }, + ShieldedCreditsShielded { + seed_hash: WalletSeedHash, + amount: u64, + }, + ShieldedTransferComplete { + seed_hash: WalletSeedHash, + amount: u64, + }, + ShieldedCreditsUnshielded { + seed_hash: WalletSeedHash, + amount: u64, + }, + ShieldedNullifiersChecked { + seed_hash: WalletSeedHash, + spent_count: u32, + }, + ShieldedFromAssetLock { + seed_hash: WalletSeedHash, + amount: u64, + }, + ShieldedWithdrawalComplete { + seed_hash: WalletSeedHash, + amount: u64, + }, + ProvingKeyReady, } impl BackendTaskSuccessResult {} @@ -373,6 +412,9 @@ impl AppContext { 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::None => Ok(BackendTaskSuccessResult::None), } } diff --git a/src/backend_task/platform_info.rs b/src/backend_task/platform_info.rs index 54c58d212..38fb772b4 100644 --- a/src/backend_task/platform_info.rs +++ b/src/backend_task/platform_info.rs @@ -44,6 +44,7 @@ pub enum PlatformInfoTaskRequestType { CurrentWithdrawalsInQueue, RecentlyCompletedWithdrawals, BasicPlatformInfo, + ShieldedPoolState, FetchAddressBalance(String), } @@ -677,6 +678,28 @@ impl AppContext { )) } } + PlatformInfoTaskRequestType::ShieldedPoolState => { + use dash_sdk::query_types::ShieldedPoolState; + + match ShieldedPoolState::fetch_current(sdk).await { + Ok(pool_state) => { + let total_credits = pool_state.0; + let dash_amount = total_credits as f64 / (dash_to_credits!(1) as f64); + let formatted = format!( + "Shielded Pool State:\n\n\ + • Total Balance: {} credits\n\ + • Dash Equivalent: {:.8} DASH", + total_credits, dash_amount, + ); + Ok(BackendTaskSuccessResult::PlatformInfo( + PlatformInfoTaskResult::TextResult(formatted), + )) + } + Err(e) => Err(TaskError::ShieldedSyncFailed { + detail: format!("Failed to fetch shielded pool state: {}", e), + }), + } + } PlatformInfoTaskRequestType::FetchAddressBalance(address_string) => { let platform_address: PlatformAddress = address_string diff --git a/src/backend_task/shielded/bundle.rs b/src/backend_task/shielded/bundle.rs new file mode 100644 index 000000000..b6337c8c6 --- /dev/null +++ b/src/backend_task/shielded/bundle.rs @@ -0,0 +1,689 @@ +use crate::backend_task::error::TaskError; +use crate::context::AppContext; +use crate::context::shielded::get_proving_key; +use crate::model::wallet::WalletSeedHash; +use crate::model::wallet::shielded::ShieldedWalletState; +use dash_sdk::dpp::address_funds::{ + AddressFundsFeeStrategy, AddressFundsFeeStrategyStep, OrchardAddress, PlatformAddress, +}; +use dash_sdk::dpp::dashcore::Address; +use dash_sdk::dpp::identity::core_script::CoreScript; +use dash_sdk::dpp::shielded::builder::{ + OrchardProver, SpendableNote, build_shield_transition, build_shielded_transfer_transition, + build_shielded_withdrawal_transition, build_unshield_transition, +}; +use dash_sdk::dpp::withdrawal::Pooling; +use dash_sdk::grovedb_commitment_tree::{Nullifier, PaymentAddress, ProvingKey}; +use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex}; + +/// Wrapper around a cached `ProvingKey` that implements `OrchardProver`. +struct CachedProver { + key: &'static ProvingKey, +} + +impl OrchardProver for CachedProver { + fn proving_key(&self) -> &ProvingKey { + self.key + } +} + +/// Progress stage for a shield credits operation (used by batch UI). +#[derive(Clone, Debug)] +pub enum ShieldStage { + Queued, + BuildingProof { + nonce: u32, + }, + WaitingToBroadcast, + Broadcasting, + Complete, + Failed { + error: String, + st_json: Option, + }, +} + +impl ShieldStage { + pub fn is_terminal(&self) -> bool { + matches!(self, ShieldStage::Complete | ShieldStage::Failed { .. }) + } + + pub fn progress_fraction(&self) -> f32 { + match self { + ShieldStage::Queued => 0.0, + ShieldStage::BuildingProof { .. } => 0.4, + ShieldStage::WaitingToBroadcast => 0.6, + ShieldStage::Broadcasting => 0.8, + ShieldStage::Complete => 1.0, + ShieldStage::Failed { .. } => 1.0, + } + } + + pub fn label(&self) -> String { + match self { + ShieldStage::Queued => "Queued".to_string(), + ShieldStage::BuildingProof { nonce } => { + format!("Building proof... (nonce: {})", nonce) + } + ShieldStage::WaitingToBroadcast => "Waiting to broadcast...".to_string(), + ShieldStage::Broadcasting => { + "Broadcasting & waiting for nonce confirmation...".to_string() + } + ShieldStage::Complete => "Complete".to_string(), + ShieldStage::Failed { error, .. } => format!("Failed: {}", error), + } + } +} + +/// Build a Shield transition without broadcasting (for batch parallel mode). +/// +/// Returns the built `StateTransition` so the caller can broadcast in nonce order. +pub fn build_shield_credit( + app_context: &Arc, + seed_hash: &WalletSeedHash, + recipient_payment_address: &PaymentAddress, + amount: u64, + from_address: PlatformAddress, + nonce: u32, +) -> Result { + let sdk = { app_context.sdk.load().as_ref().clone() }; + + let prover = CachedProver { + key: get_proving_key(), + }; + let recipient_addr = payment_address_to_orchard(recipient_payment_address)?; + + let wallet_arc = { + let wallets = app_context.wallets.read().unwrap(); + wallets + .get(seed_hash) + .cloned() + .ok_or(TaskError::WalletNotFound)? + }; + + let mut inputs = BTreeMap::new(); + inputs.insert(from_address, (nonce, amount)); + + let fee_strategy: AddressFundsFeeStrategy = + vec![AddressFundsFeeStrategyStep::DeductFromInput(0)]; + + let wallet = wallet_arc.read().unwrap(); + build_shield_transition( + &recipient_addr, + amount, + inputs, + fee_strategy, + &*wallet, + 0, + &prover, + [0u8; 36], + sdk.version(), + ) + .map_err(|e| TaskError::ShieldedTransitionBuildFailed { + detail: e.to_string(), + }) +} + +/// Build and broadcast a Shield transition (transparent -> shielded pool). +/// +/// Uses the DPP builder which handles Orchard bundle construction internally +/// (including Halo 2 proof generation and RedPallas signature application). +pub async fn shield_credits( + app_context: &Arc, + seed_hash: &WalletSeedHash, + recipient_payment_address: &PaymentAddress, + amount: u64, + from_address: PlatformAddress, + nonce_override: Option, + stage: Option>>, +) -> Result<(), TaskError> { + let sdk = { app_context.sdk.load().as_ref().clone() }; + + let prover = CachedProver { + key: get_proving_key(), + }; + + let recipient_addr = payment_address_to_orchard(recipient_payment_address)?; + + let wallet_arc = { + let wallets = app_context.wallets.read().unwrap(); + wallets + .get(seed_hash) + .cloned() + .ok_or(TaskError::WalletNotFound)? + }; + + let nonce: u32 = if let Some(n) = nonce_override { + n + } else { + let wallet = wallet_arc.read().unwrap(); + wallet + .platform_address_info + .iter() + .find_map(|(addr, info)| { + let platform_addr = PlatformAddress::try_from(addr.clone()).ok()?; + if platform_addr == from_address { + Some(info.nonce + 1) + } else { + None + } + }) + .ok_or(TaskError::PlatformAddressNotFound)? + }; + + let mut inputs = BTreeMap::new(); + inputs.insert(from_address, (nonce, amount)); + + let fee_strategy: AddressFundsFeeStrategy = + vec![AddressFundsFeeStrategyStep::DeductFromInput(0)]; + + if let Some(s) = &stage { + *s.lock().unwrap() = ShieldStage::BuildingProof { nonce }; + } + + let state_transition = { + let wallet = wallet_arc.read().unwrap(); + build_shield_transition( + &recipient_addr, + amount, + inputs, + fee_strategy, + &*wallet, + 0, + &prover, + [0u8; 36], + sdk.version(), + ) + .map_err(|e| TaskError::ShieldedTransitionBuildFailed { + detail: e.to_string(), + })? + }; + + if let Some(s) = &stage { + *s.lock().unwrap() = ShieldStage::Broadcasting; + } + + state_transition.broadcast(&sdk, None).await.map_err(|e| { + TaskError::ShieldedBroadcastFailed { + source: Box::new(e), + } + })?; + + Ok(()) +} + +/// Build and broadcast a ShieldedTransfer transition (pool -> pool). +/// +/// Returns the nullifiers of the notes that were spent. +pub async fn shielded_transfer( + app_context: &Arc, + _seed_hash: &WalletSeedHash, + shielded_state: &ShieldedWalletState, + amount: u64, + recipient_address_bytes: &[u8], +) -> Result, TaskError> { + let sdk = { app_context.sdk.load().as_ref().clone() }; + + let prover = CachedProver { + key: get_proving_key(), + }; + + let recipient_bytes: [u8; 43] = recipient_address_bytes + .try_into() + .map_err(|_| TaskError::ShieldedInvalidRecipientAddress)?; + let recipient_addr = OrchardAddress::from_raw_bytes(&recipient_bytes) + .map_err(|_| TaskError::ShieldedInvalidRecipientAddress)?; + + let (spendable_notes, _total_value) = select_notes_for_amount(shielded_state, amount)?; + + let spent_nullifiers: Vec = spendable_notes.iter().map(|n| n.nullifier).collect(); + + let (spends, anchor) = { + let tree = shielded_state.commitment_tree.lock().unwrap(); + let spends = spendable_notes + .iter() + .map(|note| { + let merkle_path = tree + .witness(note.position, 0) + .map_err(|e| TaskError::ShieldedMerkleWitnessUnavailable { + detail: e.to_string(), + })? + .ok_or(TaskError::ShieldedMerkleWitnessUnavailable { + detail: "No Merkle path available for note".into(), + })?; + Ok(SpendableNote { + note: note.note, + merkle_path, + }) + }) + .collect::, TaskError>>()?; + + let anchor = tree + .anchor() + .map_err(|e| TaskError::ShieldedMerkleWitnessUnavailable { + detail: e.to_string(), + })?; + (spends, anchor) + }; + + let change_addr = payment_address_to_orchard(&shielded_state.keys.default_address)?; + + let state_transition = build_shielded_transfer_transition( + spends, + &recipient_addr, + amount, + &change_addr, + &shielded_state.keys.fvk, + &shielded_state.keys.ask, + anchor, + &prover, + [0u8; 36], + None, + sdk.version(), + ) + .map_err(|e| TaskError::ShieldedTransitionBuildFailed { + detail: e.to_string(), + })?; + + state_transition.broadcast(&sdk, None).await.map_err(|e| { + TaskError::ShieldedBroadcastFailed { + source: Box::new(e), + } + })?; + + Ok(spent_nullifiers) +} + +/// Build and broadcast an Unshield transition (shielded pool -> platform address). +/// +/// Returns the nullifiers of the notes that were spent. +pub async fn unshield_credits( + app_context: &Arc, + _seed_hash: &WalletSeedHash, + shielded_state: &ShieldedWalletState, + amount: u64, + to_platform_address: PlatformAddress, +) -> Result, TaskError> { + let sdk = { app_context.sdk.load().as_ref().clone() }; + + let prover = CachedProver { + key: get_proving_key(), + }; + + let (spendable_notes, _total_value) = select_notes_for_amount(shielded_state, amount)?; + + let spent_nullifiers: Vec = spendable_notes.iter().map(|n| n.nullifier).collect(); + + let (spends, anchor) = { + let tree = shielded_state.commitment_tree.lock().unwrap(); + let spends = spendable_notes + .iter() + .map(|note| { + let merkle_path = tree + .witness(note.position, 0) + .map_err(|e| TaskError::ShieldedMerkleWitnessUnavailable { + detail: e.to_string(), + })? + .ok_or(TaskError::ShieldedMerkleWitnessUnavailable { + detail: "No Merkle path available for note".into(), + })?; + Ok(SpendableNote { + note: note.note, + merkle_path, + }) + }) + .collect::, TaskError>>()?; + + let anchor = tree + .anchor() + .map_err(|e| TaskError::ShieldedMerkleWitnessUnavailable { + detail: e.to_string(), + })?; + (spends, anchor) + }; + + let change_addr = payment_address_to_orchard(&shielded_state.keys.default_address)?; + + let state_transition = build_unshield_transition( + spends, + to_platform_address, + amount, + &change_addr, + &shielded_state.keys.fvk, + &shielded_state.keys.ask, + anchor, + &prover, + [0u8; 36], + None, + sdk.version(), + ) + .map_err(|e| TaskError::ShieldedTransitionBuildFailed { + detail: e.to_string(), + })?; + + state_transition.broadcast(&sdk, None).await.map_err(|e| { + TaskError::ShieldedBroadcastFailed { + source: Box::new(e), + } + })?; + + Ok(spent_nullifiers) +} + +/// Build and broadcast a ShieldFromAssetLock transition (core DASH -> shielded pool via asset lock). +/// +/// Creates an asset lock transaction from wallet UTXOs, broadcasts it, waits for +/// an InstantLock/ChainLock proof, then builds and broadcasts a Type 18 +/// ShieldFromAssetLock state transition that deposits credits directly into the +/// shielded pool. +pub async fn shield_from_asset_lock( + app_context: &Arc, + seed_hash: &WalletSeedHash, + shielded_state: &ShieldedWalletState, + amount_duffs: u64, +) -> Result { + use dash_sdk::dashcore_rpc::RpcApi; + use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; + use dash_sdk::dpp::prelude::AssetLockProof; + use dash_sdk::dpp::shielded::builder::build_shield_from_asset_lock_transition; + use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; + use std::time::Duration; + + let proving_key = crate::context::shielded::get_proving_key(); + + let platform_fee_credits = app_context + .fee_estimator() + .min_fees() + .address_funding_asset_lock_cost; + let platform_fee_duffs = (platform_fee_credits / CREDITS_PER_DUFF).saturating_mul(120) / 100; + let asset_lock_duffs = amount_duffs.saturating_add(platform_fee_duffs); + + // Step 1: Create the asset lock transaction + let (asset_lock_transaction, asset_lock_private_key, _asset_lock_address, used_utxos) = { + let wallet_arc = { + let wallets = app_context.wallets.read().unwrap(); + wallets + .get(seed_hash) + .cloned() + .ok_or(TaskError::WalletNotFound)? + }; + + let mut wallet = wallet_arc + .write() + .map_err(|_| TaskError::LockPoisoned { resource: "wallet" })?; + + match wallet.generic_asset_lock_transaction( + app_context.as_ref(), + app_context.network, + asset_lock_duffs, + false, + ) { + Ok((tx, private_key, address, _change, utxos)) => (tx, private_key, address, utxos), + Err(_) => { + wallet + .reload_utxos(app_context.as_ref()) + .map_err(|e| TaskError::ShieldedTransitionBuildFailed { detail: e })?; + + let (tx, private_key, address, _change, utxos) = wallet + .generic_asset_lock_transaction( + app_context.as_ref(), + app_context.network, + asset_lock_duffs, + false, + ) + .map_err(|e| TaskError::ShieldedTransitionBuildFailed { detail: e })?; + (tx, private_key, address, utxos) + } + } + }; + + let tx_id = asset_lock_transaction.txid(); + + // Step 2: Register this transaction as waiting for finality + { + let mut proofs = app_context + .transactions_waiting_for_finality + .lock() + .unwrap(); + proofs.insert(tx_id, None); + } + + // Step 3: Broadcast the transaction + app_context + .core_client + .read() + .expect("Core client lock was poisoned") + .send_raw_transaction(&asset_lock_transaction)?; + + // Step 4: Remove used UTXOs from wallet + { + let wallet_arc = { + let wallets = app_context.wallets.read().unwrap(); + 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(|e| TaskError::ShieldedTransitionBuildFailed { detail: e })?; + } + + // Step 5: Wait for asset lock proof (InstantLock or ChainLock) with timeout + let asset_lock_proof: AssetLockProof; + let timeout = tokio::time::sleep(Duration::from_secs(300)); + 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().unwrap(); + if let Some(Some(proof)) = proofs.get(&tx_id) { + asset_lock_proof = proof.clone(); + break; + } + } + } + } + + // Step 6: Clean up the finality tracking + { + let mut proofs = app_context + .transactions_waiting_for_finality + .lock() + .unwrap(); + proofs.remove(&tx_id); + } + + // Step 7: Build and broadcast the shield-from-asset-lock transition + let sdk = { app_context.sdk.load().as_ref().clone() }; + + let recipient = payment_address_to_orchard(&shielded_state.keys.default_address)?; + let prover = CachedProver { key: proving_key }; + + let shield_amount_credits = + amount_duffs + .checked_mul(CREDITS_PER_DUFF) + .ok_or(TaskError::CreditCalculationOverflow { + amount: amount_duffs, + credits_per_duff: CREDITS_PER_DUFF, + })?; + + let state_transition = build_shield_from_asset_lock_transition( + &recipient, + shield_amount_credits, + asset_lock_proof, + asset_lock_private_key.inner.as_ref(), + &prover, + [0u8; 36], + sdk.version(), + ) + .map_err(|e| TaskError::ShieldedTransitionBuildFailed { + detail: e.to_string(), + })?; + + state_transition.broadcast(&sdk, None).await.map_err(|e| { + TaskError::ShieldedBroadcastFailed { + source: Box::new(e), + } + })?; + + Ok(shield_amount_credits) +} + +/// Build and broadcast a ShieldedWithdrawal transition (shielded pool -> core L1 address). +/// +/// Returns the nullifiers of the notes that were spent. +pub async fn shielded_withdrawal( + app_context: &Arc, + _seed_hash: &WalletSeedHash, + shielded_state: &ShieldedWalletState, + amount: u64, + to_core_address: Address, +) -> Result, TaskError> { + let sdk = { app_context.sdk.load().as_ref().clone() }; + + let prover = CachedProver { + key: get_proving_key(), + }; + + let output_script = CoreScript::from_bytes(to_core_address.script_pubkey().to_bytes()); + + let (spendable_notes, _total_value) = select_notes_for_amount(shielded_state, amount)?; + let spent_nullifiers: Vec = spendable_notes.iter().map(|n| n.nullifier).collect(); + + let (spends, anchor) = { + let tree = shielded_state.commitment_tree.lock().unwrap(); + let spends = spendable_notes + .iter() + .map(|note| { + let merkle_path = tree + .witness(note.position, 0) + .map_err(|e| TaskError::ShieldedMerkleWitnessUnavailable { + detail: e.to_string(), + })? + .ok_or(TaskError::ShieldedMerkleWitnessUnavailable { + detail: "No Merkle path available for note".into(), + })?; + Ok(SpendableNote { + note: note.note, + merkle_path, + }) + }) + .collect::, TaskError>>()?; + + let anchor = tree + .anchor() + .map_err(|e| TaskError::ShieldedMerkleWitnessUnavailable { + detail: e.to_string(), + })?; + (spends, anchor) + }; + + let change_addr = payment_address_to_orchard(&shielded_state.keys.default_address)?; + + let state_transition = build_shielded_withdrawal_transition( + spends, + amount, + output_script, + 1, // core_fee_per_byte + Pooling::Standard, + &change_addr, + &shielded_state.keys.fvk, + &shielded_state.keys.ask, + anchor, + &prover, + [0u8; 36], + None, + sdk.version(), + ) + .map_err(|e| TaskError::ShieldedTransitionBuildFailed { + detail: e.to_string(), + })?; + + state_transition.broadcast(&sdk, None).await.map_err(|e| { + TaskError::ShieldedBroadcastFailed { + source: Box::new(e), + } + })?; + + Ok(spent_nullifiers) +} + +/// Select notes to cover the requested amount using a greedy algorithm. +fn select_notes_for_amount( + shielded_state: &ShieldedWalletState, + amount: u64, +) -> Result<(Vec<&crate::model::wallet::shielded::ShieldedNote>, u64), TaskError> { + let unspent: Vec<_> = shielded_state.unspent_notes(); + + if unspent.is_empty() { + return Err(TaskError::ShieldedNoUnspentNotes); + } + + let total_available: u64 = unspent.iter().map(|n| n.value).sum(); + if total_available < amount { + return Err(TaskError::ShieldedInsufficientBalance { + available: total_available, + required: amount, + }); + } + + let mut sorted: Vec<_> = unspent; + sorted.sort_by(|a, b| b.value.cmp(&a.value)); + + let mut selected = Vec::new(); + let mut accumulated = 0u64; + + for note in sorted { + selected.push(note); + accumulated += note.value; + if accumulated >= amount { + break; + } + } + + Ok((selected, accumulated)) +} + +/// Convert a PaymentAddress to an OrchardAddress for the builder functions. +fn payment_address_to_orchard(addr: &PaymentAddress) -> Result { + let raw = addr.to_raw_address_bytes(); + OrchardAddress::from_raw_bytes(&raw).map_err(|_| TaskError::ShieldedInvalidRecipientAddress) +} diff --git a/src/backend_task/shielded/mod.rs b/src/backend_task/shielded/mod.rs new file mode 100644 index 000000000..c48c6987b --- /dev/null +++ b/src/backend_task/shielded/mod.rs @@ -0,0 +1,59 @@ +pub mod bundle; +pub mod nullifiers; +pub mod sync; + +use crate::model::wallet::WalletSeedHash; +use dash_sdk::dpp::address_funds::PlatformAddress; +use dash_sdk::dpp::dashcore::Address; + +#[derive(Debug, Clone, PartialEq)] +pub enum ShieldedTask { + /// Initialize shielded state for a wallet (ZIP32 key derivation, load tree from DB) + InitializeShieldedWallet { seed_hash: WalletSeedHash }, + + /// Sync encrypted notes from platform (trial decrypt, update tree) + SyncNotes { seed_hash: WalletSeedHash }, + + /// Shield credits from platform address into the shielded pool (Type 15) + ShieldCredits { + seed_hash: WalletSeedHash, + amount: u64, + from_address: PlatformAddress, + /// When set, use this nonce instead of reading from wallet (for batch parallel mode). + nonce_override: Option, + }, + + /// Private transfer within the shielded pool (Type 16) + ShieldedTransfer { + seed_hash: WalletSeedHash, + amount: u64, + /// Serialized Orchard PaymentAddress bytes + recipient_address_bytes: Vec, + }, + + /// Unshield credits to a platform address (Type 17) + UnshieldCredits { + seed_hash: WalletSeedHash, + amount: u64, + to_platform_address: PlatformAddress, + }, + + /// Check nullifiers to detect spent notes + CheckNullifiers { seed_hash: WalletSeedHash }, + + /// Shield core DASH directly into the shielded pool via asset lock (Type 18) + ShieldFromAssetLock { + seed_hash: WalletSeedHash, + amount_duffs: u64, + }, + + /// Withdraw from the shielded pool directly to a core L1 address (Type 19) + ShieldedWithdrawal { + seed_hash: WalletSeedHash, + amount: u64, + to_core_address: Address, + }, + + /// Warm up the proving key in background (~30s) + WarmUpProvingKey, +} diff --git a/src/backend_task/shielded/nullifiers.rs b/src/backend_task/shielded/nullifiers.rs new file mode 100644 index 000000000..611892b27 --- /dev/null +++ b/src/backend_task/shielded/nullifiers.rs @@ -0,0 +1,84 @@ +use crate::backend_task::error::TaskError; +use crate::context::AppContext; +use crate::model::wallet::WalletSeedHash; +use crate::model::wallet::shielded::ShieldedWalletState; +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::platform::shielded::nullifier_sync::{NullifierSyncCheckpoint, NullifierSyncConfig}; +use std::sync::Arc; + +/// Check which unspent notes have been spent on-chain using the SDK's +/// privacy-preserving nullifier sync. +/// +/// The SDK handles full tree scan vs incremental catch-up internally based +/// on the provided `last_sync_height` and `last_sync_timestamp`. +pub async fn check_nullifiers( + app_context: &Arc, + seed_hash: &WalletSeedHash, + shielded_state: &mut ShieldedWalletState, + network: Network, +) -> Result { + let sdk = { app_context.sdk.load().as_ref().clone() }; + + let network_str = network.to_string(); + + // Collect unspent nullifier bytes for the provider + let unspent_nullifiers: Vec<[u8; 32]> = shielded_state + .notes + .iter() + .filter(|n| !n.is_spent) + .map(|n| n.nullifier.to_bytes()) + .collect(); + + if unspent_nullifiers.is_empty() { + return Ok(0); + } + + let last_height = shielded_state.last_nullifier_sync_height; + let last_timestamp = shielded_state.last_nullifier_sync_timestamp; + + let last_sync = if last_height > 0 || last_timestamp > 0 { + Some(NullifierSyncCheckpoint { + height: last_height, + timestamp: last_timestamp, + }) + } else { + None + }; + + let result = sdk + .sync_nullifiers(&unspent_nullifiers, None::, last_sync) + .await + .map_err(|e| TaskError::ShieldedNullifierSyncFailed { + detail: e.to_string(), + })?; + + // Mark found (spent) nullifiers + let mut spent_count = 0u32; + for nf_bytes in &result.found { + for note in &mut shielded_state.notes { + if !note.is_spent && note.nullifier.to_bytes() == *nf_bytes { + note.is_spent = true; + spent_count += 1; + let _ = app_context + .db + .mark_shielded_note_spent(seed_hash, nf_bytes, &network_str); + } + } + } + + // Persist sync height and timestamp + shielded_state.last_nullifier_sync_height = result.new_sync_height; + shielded_state.last_nullifier_sync_timestamp = result.new_sync_timestamp; + let _ = app_context.db.set_nullifier_sync_info( + seed_hash, + &network_str, + result.new_sync_height, + result.new_sync_timestamp, + ); + + if spent_count > 0 { + shielded_state.recalculate_balance(); + } + + Ok(spent_count) +} diff --git a/src/backend_task/shielded/sync.rs b/src/backend_task/shielded/sync.rs new file mode 100644 index 000000000..5f39a8330 --- /dev/null +++ b/src/backend_task/shielded/sync.rs @@ -0,0 +1,156 @@ +use crate::backend_task::error::TaskError; +use crate::context::AppContext; +use crate::model::wallet::WalletSeedHash; +use crate::model::wallet::shielded::{ShieldedNote, ShieldedWalletState}; +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::grovedb_commitment_tree::{Position, Retention}; +use dash_sdk::platform::shielded::sync_shielded_notes; +use std::sync::Arc; + +/// Server-enforced chunk size — start_index must be a multiple of this. +const CHUNK_SIZE: u64 = 2048; + +/// Sync encrypted notes from the platform using the SDK's parallel chunk fetcher. +/// +/// On resume the raw `last_synced_index` may fall mid-chunk; we round down to +/// the nearest chunk boundary and re-fetch that partial chunk, skipping any +/// positions already present in the local tree. +pub async fn sync_notes( + app_context: &Arc, + seed_hash: &WalletSeedHash, + shielded_state: &mut ShieldedWalletState, + network: Network, +) -> Result<(u32, u64), TaskError> { + let sdk = { app_context.sdk.load().as_ref().clone() }; + + let network_str = network.to_string(); + let prepared_ivk = shielded_state.keys.ivk.prepare(); + + // Round down to nearest chunk boundary so the server accepts the query. + let already_have = shielded_state.last_synced_index; + let aligned_start = (already_have / CHUNK_SIZE) * CHUNK_SIZE; + + tracing::info!( + "Starting shielded note sync: last_synced={}, aligned_start={}", + already_have, + aligned_start, + ); + + let result = sync_shielded_notes(&sdk, &prepared_ivk, aligned_start, None) + .await + .map_err(|e| TaskError::ShieldedSyncFailed { + detail: e.to_string(), + })?; + + tracing::info!( + "Sync complete: total_scanned={}, decrypted={}, next_start_index={}", + result.total_notes_scanned, + result.decrypted_notes.len(), + result.next_start_index, + ); + + // Append notes to the local commitment tree, skipping positions already present. + // all_notes is ordered: aligned_start + i == global position of all_notes[i]. + let mut appended = 0u32; + for (i, raw_note) in result.all_notes.iter().enumerate() { + let global_pos = aligned_start + i as u64; + if global_pos < already_have { + continue; // already appended in a previous sync + } + + let cmx_bytes: [u8; 32] = + raw_note + .cmx + .as_slice() + .try_into() + .map_err(|_| TaskError::ShieldedSyncFailed { + detail: "Invalid cmx length".into(), + })?; + + let is_ours = result + .decrypted_notes + .iter() + .any(|dn| dn.position == global_pos); + let retention = if is_ours { + Retention::Marked + } else { + Retention::Ephemeral + }; + + shielded_state + .commitment_tree + .lock() + .unwrap() + .append(cmx_bytes, retention) + .map_err(|e| TaskError::ShieldedTreeUpdateFailed { + detail: e.to_string(), + })?; + + appended += 1; + } + + if appended > 0 { + let checkpoint_id = result.next_start_index as u32; + shielded_state + .commitment_tree + .lock() + .unwrap() + .checkpoint(checkpoint_id) + .map_err(|e| TaskError::ShieldedTreeUpdateFailed { + detail: e.to_string(), + })?; + } + + // Persist and record decrypted notes that are new (position >= already_have). + let mut new_note_count = 0u32; + for dn in result.decrypted_notes { + if dn.position < already_have { + continue; // already stored in a previous sync + } + + // Compute the spending nullifier from our FVK (dn.nullifier is the rho/nf + // field from the compact action, not the spending nullifier). + let nullifier = dn.note.nullifier(&shielded_state.keys.fvk); + let value = dn.note.value().inner(); + + tracing::info!("Note[{}]: DECRYPTED, value={} credits", dn.position, value,); + + let note_data = crate::model::wallet::shielded::serialize_note(&dn.note); + let nullifier_bytes = nullifier.to_bytes(); + + let _ = app_context.db.insert_shielded_note( + seed_hash, + &crate::database::shielded::InsertShieldedNote { + note_data: ¬e_data, + position: dn.position, + cmx: &dn.cmx, + nullifier: &nullifier_bytes, + block_height: 0, + value, + network: &network_str, + }, + ); + + shielded_state.notes.push(ShieldedNote { + note: dn.note, + position: Position::from(dn.position), + cmx: dn.cmx, + nullifier, + block_height: 0, + is_spent: false, + value, + }); + + new_note_count += 1; + } + + // Store the actual number of notes seen, not the chunk-rounded next_start_index. + // The SDK rounds next_start_index UP to the next 2048 boundary, which would + // make the display show 2048 even when only e.g. 150 notes exist. + // On the next sync we round down again, re-fetch the partial chunk, and skip + // positions already in the tree — so correctness is maintained. + shielded_state.last_synced_index = aligned_start + result.total_notes_scanned; + shielded_state.recalculate_balance(); + + Ok((new_note_count, shielded_state.shielded_balance)) +} diff --git a/src/context/mod.rs b/src/context/mod.rs index 83647d30a..11d354fcc 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -2,6 +2,7 @@ pub mod connection_status; mod contract_token_db; mod identity_db; mod settings_db; +pub mod shielded; mod transaction_processing; mod wallet_lifecycle; @@ -104,6 +105,13 @@ pub struct AppContext { /// Cached fee multiplier permille from current epoch (1000 = 1x, 2000 = 2x) /// Updated when epoch info is fetched from Platform fee_multiplier_permille: AtomicU64, + /// Per-wallet shielded state (initialized lazily, keyed by wallet seed hash) + pub(crate) shielded_states: Mutex< + std::collections::HashMap< + WalletSeedHash, + crate::model::wallet::shielded::ShieldedWalletState, + >, + >, /// The egui context, stored for use in non-UI code paths (e.g. display_task_result). /// Clone is O(1) — egui::Context is Arc-backed and the same instance for the app lifetime. egui_ctx: egui::Context, @@ -332,6 +340,7 @@ impl AppContext { fee_multiplier_permille: AtomicU64::new( PlatformFeeEstimator::DEFAULT_FEE_MULTIPLIER_PERMILLE, ), + shielded_states: Mutex::new(std::collections::HashMap::new()), egui_ctx, }; diff --git a/src/context/shielded.rs b/src/context/shielded.rs new file mode 100644 index 000000000..dcad9faff --- /dev/null +++ b/src/context/shielded.rs @@ -0,0 +1,511 @@ +use std::sync::OnceLock; + +use crate::backend_task::BackendTaskSuccessResult; +use crate::backend_task::error::TaskError; +use crate::backend_task::shielded::ShieldedTask; +use crate::context::AppContext; +use crate::model::wallet::shielded::{ShieldedNote, ShieldedWalletState, derive_orchard_keys}; +use dash_sdk::grovedb_commitment_tree::{ + ClientPersistentCommitmentTree, Nullifier, Position, ProvingKey, +}; +use std::sync::Arc; + +static PROVING_KEY: OnceLock = OnceLock::new(); + +/// Get or build the Halo 2 ProvingKey (cached for app lifetime). +/// +/// The first call takes ~30 seconds to build the key. Subsequent calls return +/// immediately from the cache. Use `warm_up_proving_key()` on a background +/// thread at app startup to avoid blocking the user's first shielded operation. +pub fn get_proving_key() -> &'static ProvingKey { + PROVING_KEY.get_or_init(ProvingKey::build) +} + +/// Whether the proving key has already been built and cached. +pub fn is_proving_key_ready() -> bool { + PROVING_KEY.get().is_some() +} + +impl AppContext { + /// Run a shielded pool task. + pub async fn run_shielded_task( + self: &Arc, + task: ShieldedTask, + ) -> Result { + match task { + ShieldedTask::WarmUpProvingKey => { + let _ = get_proving_key(); + Ok(BackendTaskSuccessResult::ProvingKeyReady) + } + + ShieldedTask::InitializeShieldedWallet { seed_hash } => { + self.initialize_shielded_wallet(seed_hash) + } + + ShieldedTask::SyncNotes { seed_hash } => self.sync_shielded_notes(seed_hash).await, + + ShieldedTask::ShieldCredits { + seed_hash, + amount, + from_address, + nonce_override, + } => { + self.shield_credits_task(seed_hash, amount, from_address, nonce_override) + .await + } + + ShieldedTask::ShieldedTransfer { + seed_hash, + amount, + recipient_address_bytes, + } => { + self.shielded_transfer_task(seed_hash, amount, recipient_address_bytes) + .await + } + + ShieldedTask::UnshieldCredits { + seed_hash, + amount, + to_platform_address, + } => { + self.unshield_credits_task(seed_hash, amount, to_platform_address) + .await + } + + ShieldedTask::CheckNullifiers { seed_hash } => { + self.check_nullifiers_task(seed_hash).await + } + + ShieldedTask::ShieldFromAssetLock { + seed_hash, + amount_duffs, + } => { + self.shield_from_asset_lock_task(seed_hash, amount_duffs) + .await + } + + ShieldedTask::ShieldedWithdrawal { + seed_hash, + amount, + to_core_address, + } => { + self.shielded_withdrawal_task(seed_hash, amount, to_core_address) + .await + } + } + } + + /// Increment the stored nonce for a platform address after a successful state transition. + /// + /// Updates both the in-memory wallet and the persisted DB record so that + /// subsequent operations (single-shot or batch) read the correct next nonce + /// without needing a full platform-address sync. + pub fn bump_platform_address_nonce( + &self, + seed_hash: &crate::model::wallet::WalletSeedHash, + from_address: &dash_sdk::dpp::address_funds::PlatformAddress, + ) { + let wallets = self.wallets.read().unwrap(); + let wallet_arc = match wallets.get(seed_hash) { + Some(w) => w.clone(), + None => return, + }; + drop(wallets); + + let mut wallet = wallet_arc.write().unwrap(); + // Find the matching entry (platform_address_info is keyed by core Address) + let mut found: Option<(dash_sdk::dpp::dashcore::Address, u64, u32)> = None; + for (core_addr, info) in wallet.platform_address_info.iter_mut() { + if let Ok(pa) = + dash_sdk::dpp::address_funds::PlatformAddress::try_from(core_addr.clone()) + && &pa == from_address + { + info.nonce += 1; + found = Some((core_addr.clone(), info.balance, info.nonce)); + break; + } + } + drop(wallet); + + // Persist updated nonce to DB + if let Some((core_addr, balance, new_nonce)) = found { + let _ = self.db.set_platform_address_info( + seed_hash, + &core_addr, + balance, + new_nonce, + &self.network, + ); + } + } + + /// Get the default shielded payment address for a wallet. + pub fn shielded_default_address( + &self, + seed_hash: &crate::model::wallet::WalletSeedHash, + ) -> Option { + let states = self.shielded_states.lock().unwrap(); + states.get(seed_hash).map(|s| s.keys.default_address) + } + + /// Initialize shielded wallet state by deriving ZIP32 keys from the wallet seed. + fn initialize_shielded_wallet( + self: &Arc, + seed_hash: crate::model::wallet::WalletSeedHash, + ) -> Result { + // Check if already initialized + { + let states = self.shielded_states.lock().unwrap(); + if states.contains_key(&seed_hash) { + let balance = states + .get(&seed_hash) + .map(|s| s.shielded_balance) + .unwrap_or(0); + return Ok(BackendTaskSuccessResult::ShieldedInitialized { seed_hash, balance }); + } + } + + // Get the wallet seed + let seed_bytes = { + let wallets = self.wallets.read().unwrap(); + let wallet_arc = wallets.get(&seed_hash).ok_or(TaskError::WalletNotFound)?; + let wallet = wallet_arc.read().unwrap(); + match &wallet.wallet_seed { + crate::model::wallet::WalletSeed::Open(open) => open.seed, + crate::model::wallet::WalletSeed::Closed(_) => { + return Err(TaskError::WalletLocked); + } + } + }; + + let keys = derive_orchard_keys(&seed_bytes, self.network, 0) + .map_err(|e| TaskError::ShieldedTransitionBuildFailed { detail: e })?; + + let network_str = self.network.to_string(); + + let commitment_tree = ClientPersistentCommitmentTree::open_on_shared_connection( + self.db.shared_connection(), + 100, + ) + .map_err(|e| TaskError::ShieldedTreeUpdateFailed { + detail: e.to_string(), + })?; + + let mut last_synced_index = 0u64; + + // Resume from persisted tree state if available + if let Ok(Some(pos)) = commitment_tree.max_leaf_position() { + last_synced_index = u64::from(pos) + 1; + } + + let (last_nullifier_sync_height, last_nullifier_sync_timestamp) = self + .db + .get_nullifier_sync_info(&seed_hash, &network_str) + .unwrap_or((0, 0)); + + let mut state = ShieldedWalletState { + keys, + notes: Vec::new(), + commitment_tree: std::sync::Mutex::new(commitment_tree), + last_synced_index, + last_nullifier_sync_height, + last_nullifier_sync_timestamp, + shielded_balance: 0, + last_notes_synced_at: None, + last_nullifiers_synced_at: None, + }; + + // Load persisted notes from DB and reconstruct Note objects + if let Ok(note_rows) = self.db.get_unspent_shielded_notes(&seed_hash, &network_str) { + for row in note_rows { + if let Some(note) = crate::model::wallet::shielded::deserialize_note(&row.note_data) + && let Some(nullifier) = Nullifier::from_bytes(&row.nullifier).into_option() + { + state.notes.push(ShieldedNote { + note, + position: Position::from(row.position), + cmx: row.cmx, + nullifier, + block_height: row.block_height, + is_spent: false, + value: row.value, + }); + } + } + state.recalculate_balance(); + } + + let balance = state.shielded_balance; + + let mut states = self.shielded_states.lock().unwrap(); + states.insert(seed_hash, state); + + Ok(BackendTaskSuccessResult::ShieldedInitialized { seed_hash, balance }) + } + + /// Sync shielded notes from platform. + async fn sync_shielded_notes( + self: &Arc, + seed_hash: crate::model::wallet::WalletSeedHash, + ) -> Result { + // Take the state temporarily for the async operation + let mut state = { + let mut states = self.shielded_states.lock().unwrap(); + states.remove(&seed_hash).ok_or(TaskError::WalletNotFound)? + }; + + let result = crate::backend_task::shielded::sync::sync_notes( + self, + &seed_hash, + &mut state, + self.network, + ) + .await; + + if result.is_ok() { + state.last_notes_synced_at = Some(std::time::Instant::now()); + } + + // Put state back + { + let mut states = self.shielded_states.lock().unwrap(); + states.insert(seed_hash, state); + } + + let (new_notes, balance) = result?; + Ok(BackendTaskSuccessResult::ShieldedNotesSynced { + seed_hash, + new_notes, + balance, + }) + } + + /// Shield credits from a platform address into the shielded pool. + /// + /// Unlike other shielded operations, shield_credits only needs the + /// payment address from the shielded state (no tree or notes access). + /// We read it without removing the state so parallel operations can share it. + async fn shield_credits_task( + self: &Arc, + seed_hash: crate::model::wallet::WalletSeedHash, + amount: u64, + from_address: dash_sdk::dpp::address_funds::PlatformAddress, + nonce_override: Option, + ) -> Result { + let default_address = { + let states = self.shielded_states.lock().unwrap(); + let state = states.get(&seed_hash).ok_or(TaskError::WalletNotFound)?; + state.keys.default_address + }; + + crate::backend_task::shielded::bundle::shield_credits( + self, + &seed_hash, + &default_address, + amount, + from_address, + nonce_override, + None, + ) + .await?; + + self.bump_platform_address_nonce(&seed_hash, &from_address); + + Ok(BackendTaskSuccessResult::ShieldedCreditsShielded { seed_hash, amount }) + } + + /// Transfer credits within the shielded pool. + async fn shielded_transfer_task( + self: &Arc, + seed_hash: crate::model::wallet::WalletSeedHash, + amount: u64, + recipient_address_bytes: Vec, + ) -> Result { + let mut state = { + let mut states = self.shielded_states.lock().unwrap(); + states.remove(&seed_hash).ok_or(TaskError::WalletNotFound)? + }; + + let result = crate::backend_task::shielded::bundle::shielded_transfer( + self, + &seed_hash, + &state, + amount, + &recipient_address_bytes, + ) + .await; + + // On success, mark the spent notes immediately + if let Ok(ref spent_nullifiers) = result { + self.mark_notes_spent(&seed_hash, &mut state, spent_nullifiers); + } + + // Put state back + { + let mut states = self.shielded_states.lock().unwrap(); + states.insert(seed_hash, state); + } + + result?; + Ok(BackendTaskSuccessResult::ShieldedTransferComplete { seed_hash, amount }) + } + + /// Unshield credits from the shielded pool to a platform address. + async fn unshield_credits_task( + self: &Arc, + seed_hash: crate::model::wallet::WalletSeedHash, + amount: u64, + to_platform_address: dash_sdk::dpp::address_funds::PlatformAddress, + ) -> Result { + let mut state = { + let mut states = self.shielded_states.lock().unwrap(); + states.remove(&seed_hash).ok_or(TaskError::WalletNotFound)? + }; + + let result = crate::backend_task::shielded::bundle::unshield_credits( + self, + &seed_hash, + &state, + amount, + to_platform_address, + ) + .await; + + // On success, mark the spent notes immediately + if let Ok(ref spent_nullifiers) = result { + self.mark_notes_spent(&seed_hash, &mut state, spent_nullifiers); + } + + // Put state back + { + let mut states = self.shielded_states.lock().unwrap(); + states.insert(seed_hash, state); + } + + result?; + Ok(BackendTaskSuccessResult::ShieldedCreditsUnshielded { seed_hash, amount }) + } + + /// Withdraw credits from the shielded pool to a core L1 address. + async fn shielded_withdrawal_task( + self: &Arc, + seed_hash: crate::model::wallet::WalletSeedHash, + amount: u64, + to_core_address: dash_sdk::dpp::dashcore::Address, + ) -> Result { + let mut state = { + let mut states = self.shielded_states.lock().unwrap(); + states.remove(&seed_hash).ok_or(TaskError::WalletNotFound)? + }; + + let result = crate::backend_task::shielded::bundle::shielded_withdrawal( + self, + &seed_hash, + &state, + amount, + to_core_address, + ) + .await; + + if let Ok(ref spent_nullifiers) = result { + self.mark_notes_spent(&seed_hash, &mut state, spent_nullifiers); + } + + { + let mut states = self.shielded_states.lock().unwrap(); + states.insert(seed_hash, state); + } + + result?; + Ok(BackendTaskSuccessResult::ShieldedWithdrawalComplete { seed_hash, amount }) + } + + /// Shield core DASH directly into the shielded pool via asset lock. + async fn shield_from_asset_lock_task( + self: &Arc, + seed_hash: crate::model::wallet::WalletSeedHash, + amount_duffs: u64, + ) -> Result { + let state_ref = { + let mut states = self.shielded_states.lock().unwrap(); + states.remove(&seed_hash).ok_or(TaskError::WalletNotFound)? + }; + + let result = crate::backend_task::shielded::bundle::shield_from_asset_lock( + self, + &seed_hash, + &state_ref, + amount_duffs, + ) + .await; + + // Put state back + { + let mut states = self.shielded_states.lock().unwrap(); + states.insert(seed_hash, state_ref); + } + + let credits = result?; + Ok(BackendTaskSuccessResult::ShieldedFromAssetLock { + seed_hash, + amount: credits, + }) + } + + /// Check nullifiers to detect spent notes. + async fn check_nullifiers_task( + self: &Arc, + seed_hash: crate::model::wallet::WalletSeedHash, + ) -> Result { + let mut state = { + let mut states = self.shielded_states.lock().unwrap(); + states.remove(&seed_hash).ok_or(TaskError::WalletNotFound)? + }; + + let result = crate::backend_task::shielded::nullifiers::check_nullifiers( + self, + &seed_hash, + &mut state, + self.network, + ) + .await; + + if result.is_ok() { + state.last_nullifiers_synced_at = Some(std::time::Instant::now()); + } + + // Put state back + { + let mut states = self.shielded_states.lock().unwrap(); + states.insert(seed_hash, state); + } + + let spent_count = result?; + Ok(BackendTaskSuccessResult::ShieldedNullifiersChecked { + seed_hash, + spent_count, + }) + } + + /// Mark notes as spent in both memory and DB after a successful broadcast. + fn mark_notes_spent( + &self, + seed_hash: &crate::model::wallet::WalletSeedHash, + state: &mut ShieldedWalletState, + spent_nullifiers: &[Nullifier], + ) { + let network_str = self.network.to_string(); + for nf in spent_nullifiers { + let nf_bytes = nf.to_bytes(); + for note in &mut state.notes { + if !note.is_spent && note.nullifier.to_bytes() == nf_bytes { + note.is_spent = true; + let _ = self + .db + .mark_shielded_note_spent(seed_hash, &nf_bytes, &network_str); + } + } + } + state.recalculate_balance(); + } +} diff --git a/src/database/initialization.rs b/src/database/initialization.rs index a7ff419b8..512214be9 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -914,14 +914,7 @@ impl Database { Ok(()) } - /// Migration 29: rename network value "dash" to "mainnet" in all tables. - /// - /// Upstream `dashcore` renamed `Network::Dash` to `Network::Mainnet`, - /// which changes `Display`/`FromStr` representations from `"dash"` to - /// `"mainnet"`. This migration updates every table that stores the - /// network as a string. /// Migration 30: add `status` column to `wallet_transactions`. - /// Default 2 (Confirmed) — all pre-existing rows were confirmed transactions. fn add_wallet_transaction_status_column(&self, conn: &Connection) -> rusqlite::Result<()> { let has_status: bool = conn.query_row( "SELECT COUNT(*) FROM pragma_table_info('wallet_transactions') WHERE name='status'", @@ -930,6 +923,9 @@ impl Database { )?; if !has_status { conn.execute( + // DEFAULT 2 (Confirmed) for migration: existing transactions predate status + // tracking and are assumed confirmed. Fresh installs use DEFAULT 0 (Unconfirmed) + // in the CREATE TABLE (wallet.rs). "ALTER TABLE wallet_transactions ADD COLUMN status INTEGER NOT NULL DEFAULT 2", [], )?; @@ -937,65 +933,14 @@ impl Database { Ok(()) } - /// Create shielded pool tables. Idempotent (IF NOT EXISTS). - fn create_shielded_tables(&self, conn: &Connection) -> rusqlite::Result<()> { - conn.execute( - "CREATE TABLE IF NOT EXISTS shielded_notes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - wallet_seed_hash BLOB NOT NULL, - note_data BLOB NOT NULL, - position INTEGER NOT NULL, - cmx BLOB NOT NULL, - nullifier BLOB NOT NULL, - block_height INTEGER NOT NULL, - is_spent INTEGER NOT NULL DEFAULT 0, - value INTEGER NOT NULL, - network TEXT NOT NULL, - UNIQUE(wallet_seed_hash, nullifier, network), - FOREIGN KEY (wallet_seed_hash) REFERENCES wallet(seed_hash) ON DELETE CASCADE - )", - [], - )?; - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_shielded_notes_wallet_network - ON shielded_notes (wallet_seed_hash, network)", - [], - )?; - Ok(()) - } - - /// Create shielded wallet metadata table. Idempotent (IF NOT EXISTS). - fn create_shielded_wallet_meta_table(&self, conn: &Connection) -> rusqlite::Result<()> { - conn.execute( - "CREATE TABLE IF NOT EXISTS shielded_wallet_meta ( - wallet_seed_hash BLOB NOT NULL, - network TEXT NOT NULL, - last_nullifier_sync_height INTEGER NOT NULL DEFAULT 0, - last_nullifier_sync_timestamp INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (wallet_seed_hash, network), - FOREIGN KEY (wallet_seed_hash) REFERENCES wallet(seed_hash) ON DELETE CASCADE - )", - [], - )?; - Ok(()) - } - - /// Add last_nullifier_sync_timestamp column to shielded_wallet_meta. Idempotent. - fn add_nullifier_sync_timestamp_column(&self, conn: &Connection) -> rusqlite::Result<()> { - let has_column: bool = conn.query_row( - "SELECT COUNT(*) FROM pragma_table_info('shielded_wallet_meta') WHERE name='last_nullifier_sync_timestamp'", - [], - |row| row.get::<_, i32>(0).map(|count| count > 0), - )?; - if !has_column { - conn.execute( - "ALTER TABLE shielded_wallet_meta ADD COLUMN last_nullifier_sync_timestamp INTEGER NOT NULL DEFAULT 0", - [], - )?; - } - Ok(()) - } + // Shielded table helpers (create_shielded_tables, create_shielded_wallet_meta_table, + // add_nullifier_sync_timestamp_column) are implemented in database/shielded.rs. + /// Migration 29: rename network value `"dash"` to `"mainnet"` in all tables. + /// + /// Upstream `dashcore` renamed `Network::Dash` to `Network::Mainnet`, + /// changing the `Display`/`FromStr` representation. This migration updates + /// every table that stores the network as a string column. fn rename_network_dash_to_mainnet(&self, conn: &Connection) -> rusqlite::Result<()> { let tables = [ "settings", diff --git a/src/database/mod.rs b/src/database/mod.rs index e7f1dc902..beff03f6a 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -8,6 +8,7 @@ mod initialization; mod proof_log; mod scheduled_votes; mod settings; +pub mod shielded; mod single_key_wallet; #[cfg(any(test, feature = "testing"))] pub mod test_helpers; @@ -72,8 +73,7 @@ impl Database { /// /// Used by `ClientPersistentCommitmentTree` to share the same SQLite /// connection for the shielded commitment tree tables. - #[allow(dead_code)] // Prepared for ClientPersistentCommitmentTree - pub(crate) fn shared_connection(&self) -> Arc> { + pub fn shared_connection(&self) -> Arc> { self.conn.clone() } @@ -183,6 +183,19 @@ impl Database { rusqlite::params![&network_str], )?; + tx.execute( + "DELETE FROM shielded_notes WHERE network = ?1", + rusqlite::params![&network_str], + )?; + + // Clear commitment tree tables (persistent shielded tree data). + // These tables are created by grovedb on first use, so they may not + // exist yet — ignore errors from missing tables. + let _ = tx.execute("DELETE FROM commitment_tree_shards", []); + let _ = tx.execute("DELETE FROM commitment_tree_cap", []); + let _ = tx.execute("DELETE FROM commitment_tree_checkpoints", []); + let _ = tx.execute("DELETE FROM commitment_tree_checkpoint_marks_removed", []); + tx.commit() } } diff --git a/src/database/shielded.rs b/src/database/shielded.rs new file mode 100644 index 000000000..41ba1eddb --- /dev/null +++ b/src/database/shielded.rs @@ -0,0 +1,316 @@ +use crate::database::Database; +use crate::model::wallet::WalletSeedHash; +use rusqlite::{Connection, params}; + +impl Database { + /// Create shielded pool tables (v28 migration). + pub(crate) fn create_shielded_tables(&self, conn: &Connection) -> rusqlite::Result<()> { + conn.execute( + "CREATE TABLE IF NOT EXISTS shielded_notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + wallet_seed_hash BLOB NOT NULL, + note_data BLOB NOT NULL, + position INTEGER NOT NULL, + cmx BLOB NOT NULL, + nullifier BLOB NOT NULL, + block_height INTEGER NOT NULL, + is_spent INTEGER NOT NULL DEFAULT 0, + value INTEGER NOT NULL, + network TEXT NOT NULL, + UNIQUE(wallet_seed_hash, nullifier, network), + FOREIGN KEY (wallet_seed_hash) REFERENCES wallet(seed_hash) ON DELETE CASCADE + )", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_shielded_notes_wallet_network + ON shielded_notes (wallet_seed_hash, network)", + [], + )?; + + Ok(()) + } + + /// Insert a shielded note into the database. + pub fn insert_shielded_note( + &self, + wallet_seed_hash: &WalletSeedHash, + note: &InsertShieldedNote<'_>, + ) -> rusqlite::Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT OR IGNORE INTO shielded_notes + (wallet_seed_hash, note_data, position, cmx, nullifier, block_height, value, network) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + wallet_seed_hash.as_slice(), + note.note_data, + note.position as i64, + note.cmx.as_slice(), + note.nullifier.as_slice(), + note.block_height as i64, + note.value as i64, + note.network, + ], + )?; + Ok(()) + } + + /// Get all unspent shielded notes for a wallet on a given network. + pub fn get_unspent_shielded_notes( + &self, + wallet_seed_hash: &WalletSeedHash, + network: &str, + ) -> rusqlite::Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, note_data, position, cmx, nullifier, block_height, value + FROM shielded_notes + WHERE wallet_seed_hash = ?1 AND network = ?2 AND is_spent = 0 + ORDER BY position ASC", + )?; + + let rows = stmt.query_map(params![wallet_seed_hash.as_slice(), network], |row| { + Ok(ShieldedNoteRow { + id: row.get(0)?, + note_data: row.get(1)?, + position: row.get::<_, i64>(2)? as u64, + cmx: { + let bytes: Vec = row.get(3)?; + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bytes); + arr + }, + nullifier: { + let bytes: Vec = row.get(4)?; + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bytes); + arr + }, + block_height: row.get::<_, i64>(5)? as u64, + value: row.get::<_, i64>(6)? as u64, + }) + })?; + + rows.collect() + } + + /// Get all shielded notes (spent and unspent) for a wallet on a given network. + pub fn get_all_shielded_notes( + &self, + wallet_seed_hash: &WalletSeedHash, + network: &str, + ) -> rusqlite::Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, note_data, position, cmx, nullifier, block_height, value + FROM shielded_notes + WHERE wallet_seed_hash = ?1 AND network = ?2 + ORDER BY position ASC", + )?; + + let rows = stmt.query_map(params![wallet_seed_hash.as_slice(), network], |row| { + Ok(ShieldedNoteRow { + id: row.get(0)?, + note_data: row.get(1)?, + position: row.get::<_, i64>(2)? as u64, + cmx: { + let bytes: Vec = row.get(3)?; + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bytes); + arr + }, + nullifier: { + let bytes: Vec = row.get(4)?; + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bytes); + arr + }, + block_height: row.get::<_, i64>(5)? as u64, + value: row.get::<_, i64>(6)? as u64, + }) + })?; + + rows.collect() + } + + /// Mark a shielded note as spent by its nullifier. + pub fn mark_shielded_note_spent( + &self, + wallet_seed_hash: &WalletSeedHash, + nullifier: &[u8; 32], + network: &str, + ) -> rusqlite::Result { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE shielded_notes SET is_spent = 1 + WHERE wallet_seed_hash = ?1 AND nullifier = ?2 AND network = ?3", + params![wallet_seed_hash.as_slice(), nullifier.as_slice(), network], + ) + } + + /// Delete all shielded notes for a wallet (used by resync). + pub fn delete_shielded_notes( + &self, + wallet_seed_hash: &WalletSeedHash, + network: &str, + ) -> rusqlite::Result { + let conn = self.conn.lock().unwrap(); + conn.execute( + "DELETE FROM shielded_notes WHERE wallet_seed_hash = ?1 AND network = ?2", + params![wallet_seed_hash.as_slice(), network], + ) + } + + /// Clear all commitment tree SQLite tables (used by resync). + /// + /// The `ClientPersistentCommitmentTree` stores its shards, caps, and + /// checkpoints in `commitment_tree_*` tables. This deletes all rows so a + /// fresh tree can be opened on the same connection. + pub fn clear_commitment_tree_tables(&self) -> rusqlite::Result<()> { + let conn = self.conn.lock().unwrap(); + // Tables are created by grovedb on first use; ignore errors if missing. + let _ = conn.execute("DELETE FROM commitment_tree_shards", []); + let _ = conn.execute("DELETE FROM commitment_tree_cap", []); + let _ = conn.execute("DELETE FROM commitment_tree_checkpoints", []); + let _ = conn.execute("DELETE FROM commitment_tree_checkpoint_marks_removed", []); + Ok(()) + } + + /// Create the shielded_wallet_meta table (v29 migration). + pub(crate) fn create_shielded_wallet_meta_table( + &self, + conn: &Connection, + ) -> rusqlite::Result<()> { + conn.execute( + "CREATE TABLE IF NOT EXISTS shielded_wallet_meta ( + wallet_seed_hash BLOB NOT NULL, + network TEXT NOT NULL, + last_nullifier_sync_height INTEGER NOT NULL DEFAULT 0, + last_nullifier_sync_timestamp INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (wallet_seed_hash, network), + FOREIGN KEY (wallet_seed_hash) REFERENCES wallet(seed_hash) ON DELETE CASCADE + )", + [], + )?; + Ok(()) + } + + /// Migration: Add last_nullifier_sync_timestamp column (v30). + pub(crate) fn add_nullifier_sync_timestamp_column( + &self, + conn: &Connection, + ) -> rusqlite::Result<()> { + let table_exists: bool = conn.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='shielded_wallet_meta'", + [], + |row| row.get::<_, i32>(0).map(|count| count > 0), + )?; + + if table_exists { + let has_column: bool = conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info('shielded_wallet_meta') WHERE name='last_nullifier_sync_timestamp'", + [], + |row| row.get::<_, i32>(0).map(|count| count > 0), + )?; + + if !has_column { + conn.execute( + "ALTER TABLE shielded_wallet_meta ADD COLUMN last_nullifier_sync_timestamp INTEGER NOT NULL DEFAULT 0", + [], + )?; + } + } + + Ok(()) + } + + /// Get the last nullifier sync height and timestamp for a wallet on a given network. + pub fn get_nullifier_sync_info( + &self, + wallet_seed_hash: &WalletSeedHash, + network: &str, + ) -> Result<(u64, u64), String> { + let conn = self.conn.lock().unwrap(); + let result = conn.query_row( + "SELECT last_nullifier_sync_height, last_nullifier_sync_timestamp FROM shielded_wallet_meta + WHERE wallet_seed_hash = ?1 AND network = ?2", + params![wallet_seed_hash.as_slice(), network], + |row| { + let height: i64 = row.get(0)?; + let timestamp: i64 = row.get(1)?; + Ok((height as u64, timestamp as u64)) + }, + ); + match result { + Ok(info) => Ok(info), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok((0, 0)), + Err(e) => Err(format!("Failed to get nullifier sync info: {e}")), + } + } + + /// Set the last nullifier sync height and timestamp for a wallet on a given network. + pub fn set_nullifier_sync_info( + &self, + wallet_seed_hash: &WalletSeedHash, + network: &str, + height: u64, + timestamp: u64, + ) -> Result<(), String> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT OR REPLACE INTO shielded_wallet_meta + (wallet_seed_hash, network, last_nullifier_sync_height, last_nullifier_sync_timestamp) + VALUES (?1, ?2, ?3, ?4)", + params![ + wallet_seed_hash.as_slice(), + network, + height as i64, + timestamp as i64 + ], + ) + .map_err(|e| format!("Failed to set nullifier sync info: {e}"))?; + Ok(()) + } + + /// Get total shielded balance (sum of unspent note values) for a wallet. + pub fn get_shielded_balance( + &self, + wallet_seed_hash: &WalletSeedHash, + network: &str, + ) -> rusqlite::Result { + let conn = self.conn.lock().unwrap(); + let result: i64 = conn + .query_row( + "SELECT COALESCE(SUM(value), 0) FROM shielded_notes + WHERE wallet_seed_hash = ?1 AND network = ?2 AND is_spent = 0", + params![wallet_seed_hash.as_slice(), network], + |row| row.get(0), + ) + .unwrap_or(0); + Ok(result as u64) + } +} + +/// Parameters for inserting a shielded note. +pub struct InsertShieldedNote<'a> { + pub note_data: &'a [u8], + pub position: u64, + pub cmx: &'a [u8; 32], + pub nullifier: &'a [u8; 32], + pub block_height: u64, + pub value: u64, + pub network: &'a str, +} + +/// Row data for a shielded note from the database. +pub struct ShieldedNoteRow { + pub id: i64, + pub note_data: Vec, + pub position: u64, + pub cmx: [u8; 32], + pub nullifier: [u8; 32], + pub block_height: u64, + pub value: u64, +} diff --git a/src/mcp/dispatch.rs b/src/mcp/dispatch.rs index f52895dab..deb435cbb 100644 --- a/src/mcp/dispatch.rs +++ b/src/mcp/dispatch.rs @@ -12,12 +12,22 @@ use std::sync::Arc; /// for the GUI event loop. In MCP/CLI mode there is no GUI consuming the /// receiver, so it is intentionally dropped. The task result is returned /// directly from the async call rather than through the channel. -/// Same pattern as `tests/backend-e2e/framework/task_runner.rs`. +/// +/// Uses `spawn_blocking` + `block_on` to avoid `Send` bound issues with +/// platform SDK types (`DataContract`/`Sdk` references across await points). +/// Same pattern as `AppState::handle_backend_task` in `app.rs`. pub(crate) async fn dispatch_task( app_context: &Arc, task: BackendTask, ) -> Result { - let (tx, _rx) = tokio::sync::mpsc::channel::(32); - let sender = crate::utils::egui_mpsc::SenderAsync::new(tx, egui::Context::default()); - app_context.run_backend_task(task, sender).await + let app_context = app_context.clone(); + let handle = tokio::runtime::Handle::current(); + tokio::task::spawn_blocking(move || { + handle.block_on(async move { + let (tx, _) = tokio::sync::mpsc::channel::(32); + let sender = crate::utils::egui_mpsc::SenderAsync::new(tx, egui::Context::default()); + app_context.run_backend_task(task, sender).await + }) + }) + .await? } diff --git a/src/model/wallet/mod.rs b/src/model/wallet/mod.rs index c186dd9ad..7edc4aa42 100644 --- a/src/model/wallet/mod.rs +++ b/src/model/wallet/mod.rs @@ -1,5 +1,6 @@ mod asset_lock_transaction; pub mod encryption; +pub mod shielded; pub mod single_key; mod utxos; @@ -3003,9 +3004,8 @@ mod tests { /// Helper: register a wallet address in the test database so that /// `update_address_balance` can find the row. + /// Caller must store the wallet first via `db.store_wallet()`. fn register_test_address(db: &Database, wallet: &Wallet, address: &Address) { - db.store_wallet(wallet, &Network::Testnet) - .expect("store test wallet"); let seed_hash = wallet.seed_hash(); let path = DerivationPath::from(vec![ ChildNumber::Hardened { index: 44 }, @@ -3037,6 +3037,8 @@ mod tests { assert_eq!(wallet.max_balance(), 300_000); let db = create_test_database().expect("test db"); + db.store_wallet(&wallet, &Network::Testnet) + .expect("store test wallet"); register_test_address(&db, &wallet, &addr); let (selected, _) = wallet .select_unspent_utxos_for(90_000, 10_000, false) @@ -3057,6 +3059,8 @@ mod tests { add_utxo(&mut wallet, &addr, 1, 0, 100_000); let db = create_test_database().expect("test db"); + db.store_wallet(&wallet, &Network::Testnet) + .expect("store test wallet"); register_test_address(&db, &wallet, &addr); let (selected, _) = wallet .select_unspent_utxos_for(90_000, 10_000, false) diff --git a/src/model/wallet/shielded.rs b/src/model/wallet/shielded.rs new file mode 100644 index 000000000..da9ebce13 --- /dev/null +++ b/src/model/wallet/shielded.rs @@ -0,0 +1,174 @@ +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::grovedb_commitment_tree::RandomSeed; +use dash_sdk::grovedb_commitment_tree::{ + ClientPersistentCommitmentTree, FullViewingKey, IncomingViewingKey, Note, NoteValue, Nullifier, + OutgoingViewingKey, PaymentAddress, Position, Rho, Scope, SpendAuthorizingKey, SpendingKey, +}; +use std::sync::Mutex; +use zip32::AccountId; + +/// Dash coin types per BIP44 +const DASH_COIN_TYPE_MAINNET: u32 = 5; +const DASH_COIN_TYPE_TESTNET: u32 = 1; + +/// Orchard key set derived from a wallet seed via ZIP32. +/// +/// ZIP32 derivation path: `m / 32' / coin_type' / account'` +/// where `purpose = 32` is the ZIP number for Orchard key derivation. +pub struct OrchardKeySet { + pub sk: SpendingKey, + pub fvk: FullViewingKey, + pub ask: SpendAuthorizingKey, + pub ivk: IncomingViewingKey, + pub ovk: OutgoingViewingKey, + pub default_address: PaymentAddress, +} + +/// Derive Orchard keys from an HD wallet seed using ZIP32. +/// +/// The seed can be 32-252 bytes. We pass the wallet's 64-byte BIP39 seed directly. +/// `SpendingKey::from_zip32_seed()` internally derives +/// `ExtendedSpendingKey::from_path(seed, &[32', coin_type', account'])`. +pub fn derive_orchard_keys( + seed_bytes: &[u8], + network: Network, + account: u32, +) -> Result { + let coin_type = match network { + Network::Mainnet => DASH_COIN_TYPE_MAINNET, + _ => DASH_COIN_TYPE_TESTNET, + }; + let account_id = + AccountId::try_from(account).map_err(|_| "Invalid account index".to_string())?; + + let sk = SpendingKey::from_zip32_seed(seed_bytes, coin_type, account_id) + .map_err(|e| format!("ZIP32 derivation failed: {e}"))?; + + let fvk = FullViewingKey::from(&sk); + let ask = SpendAuthorizingKey::from(&sk); + let ivk = fvk.to_ivk(Scope::External); + let ovk = fvk.to_ovk(Scope::External); + let default_address = fvk.address_at(0u32, Scope::External); + + Ok(OrchardKeySet { + sk, + fvk, + ask, + ivk, + ovk, + default_address, + }) +} + +/// A decrypted shielded note owned by the wallet. +pub struct ShieldedNote { + /// The decrypted Orchard note + pub note: Note, + /// Position in the commitment tree (global index) + pub position: Position, + /// Extracted note commitment bytes + pub cmx: [u8; 32], + /// Nullifier for detecting when spent + pub nullifier: Nullifier, + /// Block height where the note appeared + pub block_height: u64, + /// Whether the nullifier was seen on-chain + pub is_spent: bool, + /// Note value in credits (cached from note.value().inner()) + pub value: u64, +} + +/// Per-wallet shielded state, initialized lazily when the shielded tab is first opened. +/// +/// Manual `Debug` impl because `ClientPersistentCommitmentTree` does not implement `Debug`. +pub struct ShieldedWalletState { + /// ZIP32-derived Orchard keys (requires wallet to be unlocked) + pub keys: OrchardKeySet, + /// All tracked shielded notes + pub notes: Vec, + /// Client-side Sinsemilla commitment tree for witness generation (SQLite-backed). + /// + /// Wrapped in `Mutex` because `ClientPersistentCommitmentTree` contains a + /// SQLite `Connection` which is `!Sync`. The mutex makes + /// `ShieldedWalletState` `Sync` so `&ShieldedWalletState` is `Send` across + /// `.await` points in tokio tasks. + pub commitment_tree: Mutex, + /// Last note global position synced from platform + pub last_synced_index: u64, + /// Block height up to which nullifier sync has been completed + pub last_nullifier_sync_height: u64, + /// Timestamp of last nullifier sync + pub last_nullifier_sync_timestamp: u64, + /// Sum of unspent note values (cached) + pub shielded_balance: u64, + /// When notes were last synced (in-memory only, resets on app restart) + pub last_notes_synced_at: Option, + /// When nullifiers were last checked (in-memory only, resets on app restart) + pub last_nullifiers_synced_at: Option, +} + +impl std::fmt::Debug for ShieldedWalletState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ShieldedWalletState") + .field("notes_count", &self.notes.len()) + .field("last_synced_index", &self.last_synced_index) + .field( + "last_nullifier_sync_height", + &self.last_nullifier_sync_height, + ) + .field("shielded_balance", &self.shielded_balance) + .finish_non_exhaustive() + } +} + +impl ShieldedWalletState { + /// Recalculate the cached shielded balance from unspent notes. + pub fn recalculate_balance(&mut self) { + self.shielded_balance = self + .notes + .iter() + .filter(|n| !n.is_spent) + .map(|n| n.value) + .sum(); + } + + /// Get unspent notes. + pub fn unspent_notes(&self) -> Vec<&ShieldedNote> { + self.notes.iter().filter(|n| !n.is_spent).collect() + } +} + +/// Note serialization format (115 bytes): +/// `recipient(43) || value(8 LE) || rho(32) || rseed(32)` +const SERIALIZED_NOTE_LEN: usize = 43 + 8 + 32 + 32; + +/// Serialize an Orchard Note to 115 bytes for database persistence. +pub fn serialize_note(note: &Note) -> Vec { + let mut data = Vec::with_capacity(SERIALIZED_NOTE_LEN); + data.extend_from_slice(¬e.recipient().to_raw_address_bytes()); + data.extend_from_slice(¬e.value().inner().to_le_bytes()); + data.extend_from_slice(¬e.rho().to_bytes()); + data.extend_from_slice(note.rseed().as_bytes()); + data +} + +/// Deserialize an Orchard Note from 115 bytes. +pub fn deserialize_note(data: &[u8]) -> Option { + if data.len() != SERIALIZED_NOTE_LEN { + return None; + } + + let recipient_bytes: [u8; 43] = data[0..43].try_into().ok()?; + let recipient = PaymentAddress::from_raw_address_bytes(&recipient_bytes).into_option()?; + + let value_bytes: [u8; 8] = data[43..51].try_into().ok()?; + let value = NoteValue::from_raw(u64::from_le_bytes(value_bytes)); + + let rho_bytes: [u8; 32] = data[51..83].try_into().ok()?; + let rho = Rho::from_bytes(&rho_bytes).into_option()?; + + let rseed_bytes: [u8; 32] = data[83..115].try_into().ok()?; + let rseed = RandomSeed::from_bytes(rseed_bytes, &rho).into_option()?; + + Note::from_parts(recipient, value, rho, rseed).into_option() +} diff --git a/src/spv/manager.rs b/src/spv/manager.rs index a2e3fee31..ae884fcff 100644 --- a/src/spv/manager.rs +++ b/src/spv/manager.rs @@ -3,7 +3,6 @@ use crate::config::NetworkConfig; use crate::context::connection_status::ConnectionStatus; use crate::model::wallet::WalletSeedHash; use crate::utils::tasks::TaskManager; -use arc_swap::ArcSwapOption; use dash_sdk::dash_spv::client::config::MempoolStrategy; use dash_sdk::dash_spv::network::NetworkEvent; use dash_sdk::dash_spv::network::PeerNetworkManager; @@ -150,9 +149,8 @@ pub struct SpvManager { wallet: Arc>>, // Storage manager for direct access to SPV data (shared component from client) storage: Arc>>>>, - // Shared reference to the running SPV client (for quorum lookups, etc.) - // ArcSwapOption gives wait-free reads (quorum lookups) and atomic set/clear on start/stop. - spv_client: ArcSwapOption, + // Clone of the running SPV client for direct queries (quorum lookups, etc.) + spv_client: Arc>>, status: Arc>, last_error: Arc>>, started_at: Arc>>, @@ -316,7 +314,7 @@ impl SpvManager { network, ))), storage: Arc::new(Mutex::new(None)), - spv_client: ArcSwapOption::empty(), + 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)), @@ -587,7 +585,9 @@ impl SpvManager { *storage_guard = None; } - // spv_client is cleared asynchronously when the client stops; no action needed here. + 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; @@ -655,6 +655,16 @@ impl SpvManager { 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(); @@ -665,11 +675,6 @@ impl SpvManager { core_chain_locked_height ); - let client = self - .spv_client - .load_full() - .ok_or_else(|| "SPV client not initialized".to_string())?; - tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async { client @@ -854,9 +859,9 @@ impl SpvManager { } } - // Build the client and wrap in Arc for shared access + // Build the client (run() will call start() internally) let has_wallets = expected_wallet_count > 0; - let client = Arc::new(self.build_client(has_wallets).await?); + let client = self.build_client(has_wallets).await?; // Store the shared storage reference for later access { @@ -866,9 +871,6 @@ impl SpvManager { } } - // Store the client reference for quorum lookups (wait-free reads via ArcSwap) - self.spv_client.store(Some(Arc::clone(&client))); - // Subscribe to sync events (broadcast) let sync_rx = client.subscribe_sync_events().await; self.spawn_sync_event_handler(sync_rx); @@ -899,13 +901,26 @@ impl SpvManager { // 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 the client — handles start, monitoring, and stop internally + // 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 - self.spv_client.store(None); + { + if let Ok(mut guard) = self.spv_client.write() { + *guard = None; + } + } { let mut nm_guard = self.network_manager.write().await; *nm_guard = None; @@ -931,26 +946,48 @@ impl SpvManager { async fn run_client( self: Arc, - client: Arc, + client: SpvClient, stop_token: CancellationToken, ) -> Result<(), String> { - // client.run() handles start, monitoring loop, and stop internally. - // It returns when the cancellation token fires or an error occurs. - let result = client - .run(stop_token) - .await - .map_err(|e| format!("SPV client error: {e}")); + // 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 = {}", - if result.is_ok() { "Ok" } else { "Err" } + match &outcome { + Outcome::RunCompleted(Ok(())) => "RunCompleted(Ok)", + Outcome::RunCompleted(Err(_)) => "RunCompleted(Err)", + Outcome::Cancelled => "Cancelled", + } ); - match &result { - Ok(()) => { + match outcome { + Outcome::RunCompleted(Ok(())) => { let _ = self.write_status(SpvStatus::Stopped); } - Err(message) => { + 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. @@ -958,8 +995,11 @@ impl SpvManager { cs.set_spv_last_error(Some(message.clone())); } } + Outcome::Cancelled => { + let _ = self.write_status(SpvStatus::Stopped); + } } - result + Ok(()) } fn spawn_request_handler( diff --git a/src/ui/components/password_input.rs b/src/ui/components/password_input.rs index 2a9ba423e..970700597 100644 --- a/src/ui/components/password_input.rs +++ b/src/ui/components/password_input.rs @@ -68,12 +68,17 @@ impl PasswordInput { self } - /// Set the desired width of the text field. + /// Set the desired width of the text field (builder pattern). pub fn with_desired_width(mut self, width: f32) -> Self { self.desired_width = Some(width); self } + /// Update the desired width of the text field on an existing instance. + pub fn set_desired_width(&mut self, width: f32) { + self.desired_width = Some(width); + } + /// Render the text in a monospace font (useful for WIF keys). pub fn with_monospace(mut self) -> Self { self.monospace = true; diff --git a/src/ui/helpers.rs b/src/ui/helpers.rs index 68d42e111..7b4224718 100644 --- a/src/ui/helpers.rs +++ b/src/ui/helpers.rs @@ -1125,3 +1125,21 @@ pub fn show_group_token_success_screen_with_fee( }); action } + +/// Check if a string looks like a Platform Bech32m address. +/// +/// Supports both the current prefix (`dash1`/`tdash1`) and the legacy +/// prefix (`evo1`/`tevo1`) so that old addresses stored in the DB or +/// copied from older tools continue to work. +pub fn is_platform_address(s: &str) -> bool { + s.starts_with("dash1") + || s.starts_with("tdash1") + || s.starts_with("evo1") + || s.starts_with("tevo1") +} + +/// Human-readable hint for Platform address input fields. +pub const PLATFORM_ADDRESS_HINT: &str = "dash1... or tdash1..."; + +/// Example Platform address prefixes for error messages. +pub const PLATFORM_ADDRESS_EXAMPLES: &str = "dash1.../tdash1..."; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index d9f9b103b..f0ed439d5 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -7,6 +7,7 @@ use crate::model::qualified_identity::encrypted_key_storage::{ PrivateKeyData, WalletDerivationPath, }; use crate::model::wallet::Wallet; +use crate::model::wallet::WalletSeedHash; use crate::model::wallet::single_key::SingleKeyWallet; use crate::ui::contracts_documents::contracts_documents_screen::DocumentQueryScreen; use crate::ui::contracts_documents::document_action_screen::{ @@ -77,6 +78,10 @@ use tokens::unfreeze_tokens_screen::UnfreezeTokensScreen; use tokens::update_token_config::UpdateTokenConfigScreen; use tools::transition_visualizer_screen::TransitionVisualizerScreen; use wallets::add_new_wallet_screen::AddNewWalletScreen; +use wallets::shield_credits_screen::ShieldCreditsScreen; +use wallets::shield_from_asset_lock_screen::ShieldFromAssetLockScreen; +use wallets::shielded_send_screen::ShieldedSendScreen; +use wallets::unshield_credits_screen::UnshieldCreditsScreen; pub mod components; pub mod contracts_documents; @@ -302,6 +307,12 @@ pub enum ScreenType { AssetLockDetail([u8; 32], usize), CreateAssetLock(Arc>), + // Shielded screens + ShieldCreditsScreen(WalletSeedHash), + ShieldFromAssetLockScreen(WalletSeedHash), + ShieldedSendScreen(WalletSeedHash), + UnshieldCreditsScreen(WalletSeedHash), + // DashPay Screens DashPayContacts, DashPayProfile, @@ -417,6 +428,14 @@ impl PartialEq for ScreenType { ) => a1 == b1 && a2 == b2, (ScreenType::DashPayQRGenerator, ScreenType::DashPayQRGenerator) => true, (ScreenType::DashPayProfileSearch, ScreenType::DashPayProfileSearch) => true, + // Shielded screens + (ScreenType::ShieldCreditsScreen(_), ScreenType::ShieldCreditsScreen(_)) => true, + ( + ScreenType::ShieldFromAssetLockScreen(_), + ScreenType::ShieldFromAssetLockScreen(_), + ) => true, + (ScreenType::ShieldedSendScreen(_), ScreenType::ShieldedSendScreen(_)) => true, + (ScreenType::UnshieldCreditsScreen(_), ScreenType::UnshieldCreditsScreen(_)) => true, _ => false, } } @@ -672,6 +691,19 @@ impl ScreenType { ScreenType::DashPayProfileSearch => { Screen::DashPayProfileSearchScreen(ProfileSearchScreen::new(app_context.clone())) } + // Shielded screens + ScreenType::ShieldCreditsScreen(seed_hash) => { + Screen::ShieldCreditsScreen(ShieldCreditsScreen::new(*seed_hash, app_context)) + } + ScreenType::ShieldFromAssetLockScreen(seed_hash) => Screen::ShieldFromAssetLockScreen( + ShieldFromAssetLockScreen::new(*seed_hash, app_context), + ), + ScreenType::ShieldedSendScreen(seed_hash) => { + Screen::ShieldedSendScreen(ShieldedSendScreen::new(*seed_hash, app_context)) + } + ScreenType::UnshieldCreditsScreen(seed_hash) => { + Screen::UnshieldCreditsScreen(UnshieldCreditsScreen::new(*seed_hash, app_context)) + } } } } @@ -730,6 +762,12 @@ pub enum Screen { AssetLockDetailScreen(AssetLockDetailScreen), CreateAssetLockScreen(CreateAssetLockScreen), + // Shielded Screens + ShieldCreditsScreen(ShieldCreditsScreen), + ShieldFromAssetLockScreen(ShieldFromAssetLockScreen), + ShieldedSendScreen(ShieldedSendScreen), + UnshieldCreditsScreen(UnshieldCreditsScreen), + // DashPay Screens DashPayScreen(DashPayScreen), DashPayAddContactScreen(AddContactScreen), @@ -830,6 +868,11 @@ impl Screen { Screen::DashPayContactInfoEditorScreen(screen) => screen.app_context = app_context, Screen::DashPayQRGeneratorScreen(screen) => screen.app_context = app_context, Screen::DashPayProfileSearchScreen(screen) => screen.app_context = app_context, + // Shielded screens + Screen::ShieldCreditsScreen(screen) => screen.app_context = app_context.clone(), + Screen::ShieldFromAssetLockScreen(screen) => screen.app_context = app_context.clone(), + Screen::ShieldedSendScreen(screen) => screen.app_context = app_context.clone(), + Screen::UnshieldCreditsScreen(screen) => screen.app_context = app_context.clone(), } } } @@ -1048,6 +1091,13 @@ impl Screen { } Screen::DashPayQRGeneratorScreen(_) => ScreenType::DashPayQRGenerator, Screen::DashPayProfileSearchScreen(_) => ScreenType::DashPayProfileSearch, + // Shielded screens + Screen::ShieldCreditsScreen(s) => ScreenType::ShieldCreditsScreen(s.seed_hash), + Screen::ShieldFromAssetLockScreen(s) => { + ScreenType::ShieldFromAssetLockScreen(s.seed_hash) + } + Screen::ShieldedSendScreen(s) => ScreenType::ShieldedSendScreen(s.seed_hash), + Screen::UnshieldCreditsScreen(s) => ScreenType::UnshieldCreditsScreen(s.seed_hash), } } } @@ -1116,6 +1166,11 @@ impl ScreenLike for Screen { Screen::DashPayContactInfoEditorScreen(screen) => screen.refresh(), Screen::DashPayQRGeneratorScreen(_) => {} Screen::DashPayProfileSearchScreen(screen) => screen.refresh(), + // Shielded screens + Screen::ShieldCreditsScreen(_) => {} + Screen::ShieldFromAssetLockScreen(_) => {} + Screen::ShieldedSendScreen(_) => {} + Screen::UnshieldCreditsScreen(_) => {} } } @@ -1182,6 +1237,11 @@ impl ScreenLike for Screen { Screen::DashPayContactInfoEditorScreen(screen) => screen.refresh_on_arrival(), Screen::DashPayQRGeneratorScreen(_) => {} Screen::DashPayProfileSearchScreen(screen) => screen.refresh_on_arrival(), + // Shielded screens + Screen::ShieldCreditsScreen(_) => {} + Screen::ShieldFromAssetLockScreen(_) => {} + Screen::ShieldedSendScreen(_) => {} + Screen::UnshieldCreditsScreen(_) => {} } } @@ -1248,6 +1308,11 @@ impl ScreenLike for Screen { Screen::DashPayContactInfoEditorScreen(screen) => screen.ui(ctx), Screen::DashPayQRGeneratorScreen(screen) => screen.ui(ctx), Screen::DashPayProfileSearchScreen(screen) => screen.ui(ctx), + // Shielded screens + Screen::ShieldCreditsScreen(screen) => screen.ui(ctx), + Screen::ShieldFromAssetLockScreen(screen) => screen.ui(ctx), + Screen::ShieldedSendScreen(screen) => screen.ui(ctx), + Screen::UnshieldCreditsScreen(screen) => screen.ui(ctx), } } @@ -1348,6 +1413,13 @@ impl ScreenLike for Screen { Screen::DashPayProfileSearchScreen(screen) => { screen.display_message(message, message_type) } + // Shielded screens + Screen::ShieldCreditsScreen(screen) => screen.display_message(message, message_type), + Screen::ShieldFromAssetLockScreen(screen) => { + screen.display_message(message, message_type) + } + Screen::ShieldedSendScreen(screen) => screen.display_message(message, message_type), + Screen::UnshieldCreditsScreen(screen) => screen.display_message(message, message_type), } } @@ -1518,6 +1590,19 @@ impl ScreenLike for Screen { Screen::DashPayProfileSearchScreen(screen) => { screen.display_task_result(backend_task_success_result) } + // Shielded screens + Screen::ShieldCreditsScreen(screen) => { + screen.display_task_result(backend_task_success_result) + } + Screen::ShieldFromAssetLockScreen(screen) => { + screen.display_task_result(backend_task_success_result) + } + Screen::ShieldedSendScreen(screen) => { + screen.display_task_result(backend_task_success_result) + } + Screen::UnshieldCreditsScreen(screen) => { + screen.display_task_result(backend_task_success_result) + } } } @@ -1584,6 +1669,12 @@ impl ScreenLike for Screen { Screen::DashPayContactInfoEditorScreen(screen) => screen.display_task_error(error), Screen::DashPayQRGeneratorScreen(screen) => screen.display_task_error(error), Screen::DashPayProfileSearchScreen(screen) => screen.display_task_error(error), + + // Shielded Screens + Screen::ShieldCreditsScreen(screen) => screen.display_task_error(error), + Screen::ShieldFromAssetLockScreen(screen) => screen.display_task_error(error), + Screen::ShieldedSendScreen(screen) => screen.display_task_error(error), + Screen::UnshieldCreditsScreen(screen) => screen.display_task_error(error), } } @@ -1650,6 +1741,11 @@ impl ScreenLike for Screen { Screen::DashPayContactInfoEditorScreen(_) => {} Screen::DashPayQRGeneratorScreen(_) => {} Screen::DashPayProfileSearchScreen(_) => {} + // Shielded screens + Screen::ShieldCreditsScreen(_) => {} + Screen::ShieldFromAssetLockScreen(_) => {} + Screen::ShieldedSendScreen(_) => {} + Screen::UnshieldCreditsScreen(_) => {} } } } diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index 43d8be6d4..bf7770708 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -447,6 +447,10 @@ impl NetworkChooserScreen { ui.add_space(8.0); ui.horizontal(|ui| { + // Reserve space for "Save" and "Auto Update" buttons + item spacing + let buttons_width = 200.0; + let input_width = (ui.available_width() - buttons_width).max(100.0); + self.dashmate_password_input.set_desired_width(input_width); self.dashmate_password_input.show(ui); let save_clicked = ui.button("Save").clicked(); diff --git a/src/ui/tools/platform_info_screen.rs b/src/ui/tools/platform_info_screen.rs index 22a5d0b85..2237188a9 100644 --- a/src/ui/tools/platform_info_screen.rs +++ b/src/ui/tools/platform_info_screen.rs @@ -86,6 +86,11 @@ impl PlatformInfoScreen { "Fetch Recently Completed Withdrawals", PlatformInfoTaskRequestType::RecentlyCompletedWithdrawals, ), + ( + "shielded_pool", + "Fetch Shielded Pool State", + PlatformInfoTaskRequestType::ShieldedPoolState, + ), ]; let mut action = AppAction::None; @@ -275,6 +280,7 @@ impl ScreenLike for PlatformInfoScreen { ("validator_set", "Current Validator Set Information"), ("withdrawals_queue", "Current Withdrawals in Queue"), ("recent_withdrawals", "Recently Completed Withdrawals"), + ("shielded_pool", "Shielded Pool State"), ]; // Try to identify which task completed based on active tasks diff --git a/src/ui/wallets/mod.rs b/src/ui/wallets/mod.rs index 61eb3b228..613863181 100644 --- a/src/ui/wallets/mod.rs +++ b/src/ui/wallets/mod.rs @@ -4,5 +4,10 @@ pub mod asset_lock_detail_screen; pub mod create_asset_lock_screen; pub mod import_mnemonic_screen; pub mod send_screen; +pub mod shield_credits_screen; +pub mod shield_from_asset_lock_screen; +pub mod shielded_send_screen; +pub mod shielded_tab; pub mod single_key_send_screen; +pub mod unshield_credits_screen; pub mod wallets_screen; diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index 903f3472c..ff46ad922 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -267,6 +267,7 @@ fn allocate_platform_addresses( pub enum AddressType { Core, Platform, + Shielded, Unknown, } @@ -277,6 +278,8 @@ pub enum SourceSelection { CoreWallet, /// Use all Platform addresses (stores list of platform address, core address, and balance) PlatformAddresses(Vec<(PlatformAddress, Address, u64)>), + /// Use shielded pool balance (stores seed_hash and balance in credits) + Shielded(WalletSeedHash, u64), } /// Status of the send operation @@ -515,6 +518,11 @@ impl WalletSendScreen { return AddressType::Unknown; } + // Check for shielded address (dash1z... or tdash1z...) + if Self::is_shielded_address(trimmed) { + return AddressType::Shielded; + } + // Check for Platform address (Bech32m format per DIP-18) if crate::ui::helpers::is_platform_address_string(trimmed) { return AddressType::Platform; @@ -528,6 +536,10 @@ impl WalletSendScreen { AddressType::Unknown } + fn is_shielded_address(s: &str) -> bool { + s.starts_with("dash1z") || s.starts_with("tdash1z") + } + fn min_output_amount( &self, input_type: AddressType, @@ -552,6 +564,10 @@ impl WalletSendScreen { (AddressType::Unknown, AddressType::Platform) => Some(platform_min), (AddressType::Core, AddressType::Unknown) => Some(core_min), (AddressType::Platform, AddressType::Unknown) => Some(platform_min), + (AddressType::Shielded, AddressType::Shielded) => Some(platform_min), + (AddressType::Shielded, AddressType::Platform) => Some(platform_min), + (AddressType::Shielded, _) => Some(platform_min), + (_, AddressType::Shielded) => Some(platform_min), } } @@ -614,6 +630,35 @@ impl WalletSendScreen { .collect() } + /// Get shielded pool balance for the selected wallet (if initialized). + fn get_shielded_balance(&self) -> Option<(WalletSeedHash, u64)> { + let seed_hash = self.selected_wallet_seed_hash?; + // Try in-memory state first (most accurate, reflects optimistic spend marks) + let states = self.app_context.shielded_states.lock().unwrap(); + if let Some(state) = states.get(&seed_hash) { + let balance = state.shielded_balance; + return if balance > 0 { + Some((seed_hash, balance)) + } else { + None + }; + } + drop(states); + // Fall back to database balance (works even if shielded state is temporarily + // removed during an async operation, or if the Shielded tab was never visited) + let network_str = self.app_context.network.to_string(); + let balance = self + .app_context + .db + .get_shielded_balance(&seed_hash, &network_str) + .ok()?; + if balance > 0 { + Some((seed_hash, balance)) + } else { + None + } + } + /// Get Core wallet balance fn get_core_balance(&self) -> u64 { self.selected_wallet @@ -648,6 +693,10 @@ impl WalletSendScreen { "Platform Transfer" } (Some(SourceSelection::PlatformAddresses(_)), AddressType::Core) => "Withdraw to Core", + (Some(SourceSelection::Shielded(..)), AddressType::Shielded) => { + "Private Transfer (Shielded)" + } + (Some(SourceSelection::Shielded(..)), AddressType::Platform) => "Unshield to Platform", _ => "Send", } } @@ -713,6 +762,12 @@ impl WalletSendScreen { (SourceSelection::PlatformAddresses(addresses), AddressType::Core) => { self.send_platform_to_core(seed_hash, addresses, network) } + (SourceSelection::Shielded(sh, _), AddressType::Shielded) => { + self.send_shielded_to_shielded(sh) + } + (SourceSelection::Shielded(sh, _), AddressType::Platform) => { + self.send_shielded_to_platform(sh) + } _ => Err("Invalid source/destination combination".to_string()), } } @@ -1211,6 +1266,65 @@ impl WalletSendScreen { } } + /// Send from shielded pool to another shielded address (private transfer). + fn send_shielded_to_shielded( + &mut self, + seed_hash: WalletSeedHash, + ) -> Result { + let amount_credits = self + .amount + .as_ref() + .ok_or_else(|| "Amount is required".to_string())? + .value(); + + let recipient = self.destination_address.trim().to_string(); + let recipient_bytes = if let Ok((addr, _)) = + dash_sdk::dpp::address_funds::OrchardAddress::from_bech32m_string(&recipient) + { + addr.to_raw_bytes().to_vec() + } else { + return Err("Invalid shielded address".to_string()); + }; + + self.send_status = SendStatus::WaitingForResult; + Ok(AppAction::BackendTask( + crate::backend_task::BackendTask::ShieldedTask( + crate::backend_task::shielded::ShieldedTask::ShieldedTransfer { + seed_hash, + amount: amount_credits, + recipient_address_bytes: recipient_bytes, + }, + ), + )) + } + + /// Send from shielded pool to a platform address (unshield). + fn send_shielded_to_platform( + &mut self, + seed_hash: WalletSeedHash, + ) -> Result { + let amount_credits = self + .amount + .as_ref() + .ok_or_else(|| "Amount is required".to_string())? + .value(); + + let address_str = self.destination_address.trim(); + let (platform_addr, _) = PlatformAddress::from_bech32m_string(address_str) + .map_err(|e| format!("Invalid platform address: {e}"))?; + + self.send_status = SendStatus::WaitingForResult; + Ok(AppAction::BackendTask( + crate::backend_task::BackendTask::ShieldedTask( + crate::backend_task::shielded::ShieldedTask::UnshieldCredits { + seed_hash, + amount: amount_credits, + to_platform_address: platform_addr, + }, + ), + )) + } + fn render_source_selection(&mut self, ui: &mut Ui) { let dark_mode = ui.ctx().style().visuals.dark_mode; @@ -1317,6 +1431,52 @@ impl WalletSendScreen { }); }); } + + // Shielded balance option + let shielded_balance = self.get_shielded_balance(); + if let Some((seed_hash, balance)) = shielded_balance + && balance > 0 + { + ui.add_space(5.0); + + let is_shielded_selected = + matches!(&self.selected_source, Some(SourceSelection::Shielded(..))); + + Frame::group(ui.style()) + .fill(if is_shielded_selected { + DashColors::DASH_BLUE.gamma_multiply(0.1) + } else { + DashColors::surface(dark_mode) + }) + .stroke(if is_shielded_selected { + egui::Stroke::new(2.0, DashColors::DASH_BLUE) + } else { + egui::Stroke::new(1.0, DashColors::border_light(dark_mode)) + }) + .inner_margin(Margin::symmetric(12, 8)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + let mut selected = is_shielded_selected; + if ui.radio_value(&mut selected, true, "").changed() && selected { + self.selected_source = + Some(SourceSelection::Shielded(seed_hash, balance)); + } + ui.label( + RichText::new("Shielded Balance") + .color(DashColors::text_primary(dark_mode)) + .strong(), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label( + RichText::new(Self::format_credits(balance)) + .color(DashColors::SUCCESS) + .strong(), + ); + }); + }); + }); + } } fn render_destination_input(&mut self, ui: &mut Ui) { @@ -1337,6 +1497,7 @@ impl WalletSendScreen { let (type_text, type_color) = match dest_type { AddressType::Core => ("Core Address", DashColors::DASH_BLUE), AddressType::Platform => ("Platform Address", DashColors::PLATFORM_PURPLE), + AddressType::Shielded => ("Shielded Address", Color32::from_rgb(0, 180, 120)), AddressType::Unknown => ("", Color32::GRAY), }; ui.label( @@ -1456,12 +1617,16 @@ impl WalletSendScreen { }; (Some(total.saturating_sub(max_fee)), Some(hint)) } + Some(SourceSelection::Shielded(_, balance)) => { + (Some(*balance), Some("Shielded pool balance".to_string())) + } None => (None, None), }; let input_type = match self.selected_source { Some(SourceSelection::CoreWallet) => AddressType::Core, Some(SourceSelection::PlatformAddresses(_)) => AddressType::Platform, + Some(SourceSelection::Shielded(_, _)) => AddressType::Shielded, None => AddressType::Unknown, }; let output_type = Self::detect_address_type(&self.destination_address); @@ -2148,6 +2313,9 @@ impl WalletSendScreen { AddressType::Platform => { ("Platform", DashColors::PLATFORM_PURPLE) } + AddressType::Shielded => { + ("Shielded", Color32::from_rgb(0, 180, 120)) + } AddressType::Unknown => ("", Color32::GRAY), }; ui.label( @@ -2702,6 +2870,24 @@ impl ScreenLike for WalletSendScreen { self.send_status = SendStatus::Complete("Platform credits transferred successfully!".to_string()); } + crate::backend_task::BackendTaskSuccessResult::ShieldedTransferComplete { + amount, + .. + } => { + self.send_status = SendStatus::Complete(format!( + "Shielded transfer of {} complete!", + format_credits_as_dash(amount) + )); + } + crate::backend_task::BackendTaskSuccessResult::ShieldedCreditsUnshielded { + amount, + .. + } => { + self.send_status = SendStatus::Complete(format!( + "Unshielded {} to platform address!", + format_credits_as_dash(amount) + )); + } _ => { // Ignore other results } diff --git a/src/ui/wallets/shield_credits_screen.rs b/src/ui/wallets/shield_credits_screen.rs new file mode 100644 index 000000000..7519dd3e7 --- /dev/null +++ b/src/ui/wallets/shield_credits_screen.rs @@ -0,0 +1,731 @@ +use crate::app::AppAction; +use crate::backend_task::shielded::ShieldedTask; +use crate::backend_task::shielded::bundle::ShieldStage; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; +use crate::context::AppContext; +use crate::model::wallet::WalletSeedHash; +use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::styled::island_central_panel; +use crate::ui::components::top_panel::add_top_panel; +use crate::ui::{MessageType, RootScreenType, ScreenLike}; +use dash_sdk::dpp::address_funds::PlatformAddress; +use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; +use dash_sdk::dpp::serialization::PlatformSerializable; +use dash_sdk::dpp::state_transition::proof_result::StateTransitionProofResult; +use eframe::egui::{self, Context}; +use egui::{Color32, RichText}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +#[derive(PartialEq)] +enum Status { + NotStarted, + WaitingForResult, + BatchInProgress, + Complete, +} + +pub struct ShieldCreditsScreen { + pub app_context: Arc, + pub seed_hash: WalletSeedHash, + amount_str: String, + from_address: Option, + status: Status, + error_message: Option, + success_message: Option, + // Batch mode (dev only) + repeat_count_str: String, + parallel: bool, + batch_total: u32, + batch_succeeded: u32, + batch_failed: u32, + batch_remaining: u32, + /// Queued task to dispatch on next frame (for sequential batch mode). + pending_next_task: Option, + /// Per-operation progress for parallel batch mode. + batch_stages: Option>>>, + /// JSON of a failed state transition to show in the popup. + json_preview: Option, +} + +impl ShieldCreditsScreen { + pub fn new(seed_hash: WalletSeedHash, app_context: &Arc) -> Self { + // Try to find the first platform address from the wallet + let from_address = { + let wallets = app_context.wallets.read().unwrap(); + wallets.get(&seed_hash).and_then(|w| { + let wallet = w.read().unwrap(); + wallet + .platform_address_info + .keys() + .next() + .and_then(|addr| PlatformAddress::try_from(addr.clone()).ok()) + }) + }; + + Self { + app_context: app_context.clone(), + seed_hash, + amount_str: String::new(), + from_address, + status: Status::NotStarted, + error_message: None, + success_message: None, + repeat_count_str: "1".to_string(), + parallel: false, + batch_total: 0, + batch_succeeded: 0, + batch_failed: 0, + batch_remaining: 0, + pending_next_task: None, + batch_stages: None, + json_preview: None, + } + } + + fn parse_amount_credits(&self) -> Option { + let trimmed = self.amount_str.trim(); + if trimmed.is_empty() { + return None; + } + let dash: f64 = trimmed.parse().ok()?; + if dash <= 0.0 { + return None; + } + Some((dash * CREDITS_PER_DUFF as f64 * 1e8) as u64) + } + + fn parse_repeat_count(&self) -> u32 { + self.repeat_count_str + .trim() + .parse::() + .unwrap_or(1) + .clamp(1, 1000) + } + + /// Read the current nonce for our from_address from the wallet. + fn read_base_nonce(&self) -> Option { + let from_address = self.from_address?; + let wallets = self.app_context.wallets.read().unwrap(); + let wallet_arc = wallets.get(&self.seed_hash)?; + let wallet = wallet_arc.read().unwrap(); + wallet + .platform_address_info + .iter() + .find_map(|(addr, info)| { + let platform_addr = PlatformAddress::try_from(addr.clone()).ok()?; + if platform_addr == from_address { + Some(info.nonce) + } else { + None + } + }) + } + + /// Read the current balance (in credits) for our from_address from the wallet. + fn read_address_balance(&self) -> Option { + let from_address = self.from_address?; + let wallets = self.app_context.wallets.read().unwrap(); + let wallet_arc = wallets.get(&self.seed_hash)?; + let wallet = wallet_arc.read().unwrap(); + wallet + .platform_address_info + .iter() + .find_map(|(addr, info)| { + let platform_addr = PlatformAddress::try_from(addr.clone()).ok()?; + if platform_addr == from_address { + Some(info.balance) + } else { + None + } + }) + } + + /// Build a single ShieldCredits task with optional nonce override. + fn make_shield_task( + &self, + amount: u64, + addr: PlatformAddress, + nonce_override: Option, + ) -> BackendTask { + BackendTask::ShieldedTask(ShieldedTask::ShieldCredits { + seed_hash: self.seed_hash, + amount, + from_address: addr, + nonce_override, + }) + } + + /// Queue the next sequential batch task if any remain. + fn queue_next_sequential(&mut self) { + if self.batch_remaining > 0 + && let (Some(amount), Some(addr)) = (self.parse_amount_credits(), self.from_address) + { + self.batch_remaining -= 1; + self.pending_next_task = Some(self.make_shield_task(amount, addr, None)); + } + } + + /// Check if the sequential batch is complete and update status accordingly. + fn check_batch_complete(&mut self) { + // Only for sequential mode (parallel mode detects completion from shared state) + if self.batch_stages.is_none() + && self.batch_succeeded + self.batch_failed >= self.batch_total + { + self.status = Status::Complete; + self.success_message = Some(format!( + "Batch complete: {} succeeded, {} failed out of {}", + self.batch_succeeded, self.batch_failed, self.batch_total, + )); + } + } + + /// Spawn parallel batch: build proofs in parallel, broadcast in nonce order. + fn spawn_parallel_batch(&mut self, amount: u64, addr: PlatformAddress, repeat: u32) { + let base_nonce = match self.read_base_nonce() { + Some(n) => n, + None => { + self.error_message = Some("Could not read nonce from wallet".to_string()); + return; + } + }; + let default_address = match self.app_context.shielded_default_address(&self.seed_hash) { + Some(a) => a, + None => { + self.error_message = Some("Shielded wallet not initialized".to_string()); + return; + } + }; + + self.batch_total = repeat; + self.batch_succeeded = 0; + self.batch_failed = 0; + self.batch_remaining = 0; + self.status = Status::BatchInProgress; + + let stages: Vec>> = (0..repeat) + .map(|_| Arc::new(Mutex::new(ShieldStage::Queued))) + .collect(); + self.batch_stages = Some(stages.clone()); + + let app_ctx = self.app_context.clone(); + let seed_hash = self.seed_hash; + + // Single coordinating task: build in parallel, broadcast in order + tokio::spawn(async move { + use crate::backend_task::shielded::bundle; + use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; + + // Phase 1: Build all proofs in parallel + let build_futures: Vec<_> = (0..repeat) + .map(|i| { + let app_ctx = app_ctx.clone(); + let stage = stages[i as usize].clone(); + let nonce = base_nonce + 1 + i; + + async move { + *stage.lock().unwrap() = ShieldStage::BuildingProof { nonce }; + + let result = tokio::task::spawn_blocking(move || { + bundle::build_shield_credit( + &app_ctx, + &seed_hash, + &default_address, + amount, + addr, + nonce, + ) + }) + .await + .map_err(|e| e.to_string()) + .and_then(|r| r.map_err(|e| e.to_string())); + + match &result { + Ok(_) => { + *stage.lock().unwrap() = ShieldStage::WaitingToBroadcast; + } + Err(e) => { + *stage.lock().unwrap() = ShieldStage::Failed { + error: e.clone(), + st_json: None, + }; + } + } + + result + } + }) + .collect(); + + let build_results = futures::future::join_all(build_futures).await; + + // Phase 2: Broadcast sequentially in nonce order. + // We use broadcast_and_wait so each nonce is committed on-chain before + // the next is submitted (the platform increments the address nonce only + // after a state transition is finalised, so broadcasting without waiting + // would cause "expected N, got N+1" errors). + let sdk = { app_ctx.sdk.load().as_ref().clone() }; + + for (i, result) in build_results.into_iter().enumerate() { + let stage = &stages[i]; + match result { + Ok(state_transition) => { + *stage.lock().unwrap() = ShieldStage::Broadcasting; + + // Serialize first (while we still own the value) so we can + // show the bytes in the error popup if broadcast fails. + let st_repr: Option = + serde_json::to_string_pretty(&state_transition) + .ok() + .or_else(|| { + state_transition.serialize_to_bytes().map(hex::encode).ok() + }); + + match state_transition.broadcast(&sdk, None).await { + Ok(_) => { + // Wait for the state transition to be confirmed on-chain so + // the address nonce increments before we send the next nonce. + // If proof-response parsing fails (network version mismatch), + // fall back to a fixed delay — the broadcast still went through. + let wait_ok = state_transition + .wait_for_response::(&sdk, None) + .await + .is_ok(); + if !wait_ok { + tokio::time::sleep(Duration::from_secs(3)).await; + } + // Update stored nonce so the next batch reads the correct value + app_ctx.bump_platform_address_nonce(&seed_hash, &addr); + *stage.lock().unwrap() = ShieldStage::Complete; + } + Err(e) => { + *stage.lock().unwrap() = ShieldStage::Failed { + error: format!("Broadcast failed: {e}"), + st_json: st_repr, + }; + // Nonces are sequential — all remaining will fail too + for remaining in stages.iter().skip(i + 1) { + let mut s = remaining.lock().unwrap(); + if !s.is_terminal() { + *s = ShieldStage::Failed { + error: "Skipped: earlier nonce failed".to_string(), + st_json: None, + }; + } + } + break; + } + } + } + Err(_) => { + // Build failed — can't broadcast this or any subsequent nonce + for remaining in stages.iter().skip(i + 1) { + let mut s = remaining.lock().unwrap(); + if !s.is_terminal() { + *s = ShieldStage::Failed { + error: "Skipped: earlier nonce failed".to_string(), + st_json: None, + }; + } + } + break; + } + } + } + }); + } +} + +impl ScreenLike for ShieldCreditsScreen { + fn ui(&mut self, ctx: &Context) -> AppAction { + let mut action = add_top_panel( + ctx, + &self.app_context, + vec![ + ("Wallets", AppAction::PopScreen), + ("Shield Credits", AppAction::None), + ], + vec![], + ); + + action |= add_left_panel( + ctx, + &self.app_context, + RootScreenType::RootScreenWalletsBalances, + ); + + // Dispatch pending sequential task from previous frame + if let Some(task) = self.pending_next_task.take() { + action = AppAction::BackendTask(task); + } + + island_central_panel(ctx, |ui| { + ui.heading("Shield Credits"); + ui.add_space(10.0); + ui.label("Move credits from a platform address into the shielded pool."); + ui.add_space(15.0); + + // Error/success messages + if let Some(err) = &self.error_message { + ui.colored_label(Color32::from_rgb(255, 100, 100), err); + ui.add_space(5.0); + } + if let Some(msg) = &self.success_message { + ui.colored_label(Color32::DARK_GREEN, msg); + ui.add_space(10.0); + if ui.button("Done").clicked() { + action = AppAction::PopScreen; + } + return; + } + + // Source address display + if let Some(addr) = &self.from_address { + ui.horizontal(|ui| { + ui.label("From platform address:"); + ui.monospace(format!("{}", addr)); + if let Some(nonce) = self.read_base_nonce() { + ui.label( + RichText::new(format!("(nonce: {})", nonce)) + .color(Color32::GRAY) + .small(), + ); + } + }); + ui.add_space(10.0); + } else { + ui.colored_label( + Color32::from_rgb(255, 100, 100), + "No platform address found. Register an identity first.", + ); + return; + } + + // Balance display + if let Some(balance_credits) = self.read_address_balance() { + let balance_dash = balance_credits as f64 / CREDITS_PER_DUFF as f64 / 1e8; + ui.label( + RichText::new(format!("Available: {:.8} DASH", balance_dash)) + .color(Color32::from_rgb(100, 180, 100)), + ); + ui.add_space(5.0); + } + + // Amount input + ui.horizontal(|ui| { + ui.label("Amount (DASH):"); + ui.text_edit_singleline(&mut self.amount_str); + }); + ui.add_space(5.0); + + // Dev-mode batch controls + if self.app_context.is_developer_mode() && self.status == Status::NotStarted { + ui.add_space(10.0); + ui.horizontal(|ui| { + ui.label("Repeat"); + let te = + egui::TextEdit::singleline(&mut self.repeat_count_str).desired_width(50.0); + ui.add(te); + ui.label("times"); + }); + ui.checkbox(&mut self.parallel, "Parallel"); + } + + ui.add_space(15.0); + + // Progress display + let is_busy = + self.status == Status::WaitingForResult || self.status == Status::BatchInProgress; + + if self.status == Status::BatchInProgress { + // Clone Arc refs cheaply so we can mutate `self` inside the loop + let stages_snapshot = self.batch_stages.clone(); + if let Some(stages) = stages_snapshot { + // Parallel mode: per-operation progress bars + let all_done = stages.iter().all(|s| s.lock().unwrap().is_terminal()); + + if !all_done { + ctx.request_repaint_after(Duration::from_millis(100)); + } + + // Summary line + let succeeded = stages + .iter() + .filter(|s| matches!(*s.lock().unwrap(), ShieldStage::Complete)) + .count(); + let failed = stages + .iter() + .filter(|s| matches!(*s.lock().unwrap(), ShieldStage::Failed { .. })) + .count(); + + if all_done { + if failed > 0 { + ui.colored_label( + Color32::from_rgb(255, 100, 100), + format!( + "Batch complete: {} succeeded, {} failed out of {}", + succeeded, + failed, + stages.len(), + ), + ); + } else { + ui.colored_label( + Color32::from_rgb(50, 180, 50), + format!("Batch complete: all {} succeeded", stages.len()), + ); + } + } else { + ui.label(format!( + "Succeeded {}/{} Failed {}/{}", + succeeded, + stages.len(), + failed, + stages.len(), + )); + } + ui.add_space(5.0); + + // Collect (stage, st_json) to avoid borrow conflicts inside closure + let rows: Vec<(ShieldStage, Option)> = stages + .iter() + .map(|s| { + let s = s.lock().unwrap().clone(); + let json = if let ShieldStage::Failed { ref st_json, .. } = s { + st_json.clone() + } else { + None + }; + (s, json) + }) + .collect(); + + let total = rows.len(); + let mut pending_json: Option = None; + + egui::ScrollArea::vertical() + .max_height(400.0) + .show(ui, |ui| { + for (i, (stage, st_json)) in rows.iter().enumerate() { + let fraction = stage.progress_fraction(); + let text = format!("[{}/{}] {}", i + 1, total, stage.label()); + + let color = match stage { + ShieldStage::Queued => Color32::GRAY, + ShieldStage::BuildingProof { .. } => { + crate::ui::theme::DashColors::DASH_BLUE + } + ShieldStage::WaitingToBroadcast => { + Color32::from_rgb(100, 180, 255) + } + ShieldStage::Broadcasting => Color32::from_rgb(255, 165, 0), + ShieldStage::Complete => Color32::from_rgb(50, 180, 50), + ShieldStage::Failed { .. } => Color32::from_rgb(220, 60, 60), + }; + + if let Some(json_str) = st_json { + // Failed bar with viewable state transition: bar + button + ui.horizontal(|ui| { + let btn_width = 100.0_f32; + let bar_width = + (ui.available_width() - btn_width - 6.0).max(100.0); + ui.add_sized( + [bar_width, 20.0], + egui::ProgressBar::new(fraction).text(text).fill(color), + ); + let btn = egui::Button::new( + RichText::new("View JSON") + .color(Color32::WHITE) + .size(12.0), + ) + .fill(Color32::from_rgb(80, 80, 80)); + if ui + .add_sized([btn_width, 20.0], btn) + .on_hover_text("View state transition JSON") + .clicked() + { + pending_json = Some(json_str.clone()); + } + }); + } else { + let bar = + egui::ProgressBar::new(fraction).text(text).fill(color); + if matches!(stage, ShieldStage::BuildingProof { .. }) { + ui.add(bar.animate(true)); + } else { + ui.add(bar); + } + } + } + }); + + if let Some(json) = pending_json { + self.json_preview = Some(json); + } + + if all_done { + ui.add_space(10.0); + if ui.button("Done").clicked() { + action = AppAction::PopScreen; + } + } + } else { + // Sequential mode: simple counter + ui.horizontal(|ui| { + ui.add(egui::Spinner::new()); + ui.label(format!( + "Succeeded {}/{} Failed {}/{}", + self.batch_succeeded, + self.batch_total, + self.batch_failed, + self.batch_total, + )); + }); + } + } else if self.status == Status::WaitingForResult { + ui.horizontal(|ui| { + ui.add(egui::Spinner::new()); + ui.label("Shielding credits..."); + }); + } + + // Buttons (only when not busy) + if !is_busy && self.status == Status::NotStarted { + let can_confirm = self.parse_amount_credits().is_some(); + + ui.horizontal(|ui| { + if ui + .add_enabled( + can_confirm, + egui::Button::new( + RichText::new("Shield").color(Color32::WHITE).size(16.0), + ) + .fill(crate::ui::theme::DashColors::DASH_BLUE), + ) + .clicked() + && let (Some(amount), Some(addr)) = + (self.parse_amount_credits(), self.from_address) + { + self.error_message = None; + let repeat = if self.app_context.is_developer_mode() { + self.parse_repeat_count() + } else { + 1 + }; + + // Balance check: total cost must not exceed available balance + if let Some(balance) = self.read_address_balance() { + let total = amount.saturating_mul(repeat as u64); + if total > balance { + let total_dash = total as f64 / CREDITS_PER_DUFF as f64 / 1e8; + let balance_dash = + balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; + self.error_message = Some(format!( + "Insufficient balance: {repeat}x {:.8} DASH = {:.8} DASH total, but only {:.8} DASH available", + amount as f64 / CREDITS_PER_DUFF as f64 / 1e8, + total_dash, + balance_dash, + )); + return; + } + } + + if repeat <= 1 { + // Single operation + self.status = Status::WaitingForResult; + action = + AppAction::BackendTask(self.make_shield_task(amount, addr, None)); + } else if self.parallel { + // Parallel batch: spawn directly with progress tracking + self.spawn_parallel_batch(amount, addr, repeat); + } else { + // Sequential batch: fire first, queue rest + self.batch_total = repeat; + self.batch_succeeded = 0; + self.batch_failed = 0; + self.batch_remaining = repeat - 1; + self.status = Status::BatchInProgress; + action = + AppAction::BackendTask(self.make_shield_task(amount, addr, None)); + } + } + + ui.add_space(10.0); + if ui.button("Cancel").clicked() { + action = AppAction::PopScreen; + } + }); + } + }); + + // JSON preview popup + if let Some(json) = self.json_preview.clone() { + let mut is_open = true; + egui::Window::new("State Transition JSON") + .collapsible(false) + .resizable(true) + .max_height(500.0) + .max_width(800.0) + .scroll(true) + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) + .open(&mut is_open) + .show(ctx, |ui| { + egui::ScrollArea::vertical().show(ui, |ui| { + ui.monospace(&json); + }); + ui.add_space(10.0); + if ui.button("Copy").clicked() { + let _ = crate::ui::helpers::copy_text_to_clipboard(&json); + } + }); + if !is_open { + self.json_preview = None; + } + } + + action + } + + fn display_task_result(&mut self, result: BackendTaskSuccessResult) { + match result { + BackendTaskSuccessResult::ShieldedCreditsShielded { seed_hash, amount } + if seed_hash == self.seed_hash => + { + if self.status == Status::BatchInProgress { + // Sequential batch mode + self.batch_succeeded += 1; + self.check_batch_complete(); + if self.status == Status::BatchInProgress { + self.queue_next_sequential(); + } + } else { + self.status = Status::Complete; + let dash = amount as f64 / CREDITS_PER_DUFF as f64 / 1e8; + self.success_message = Some(format!("Successfully shielded {:.8} DASH", dash)); + } + } + _ => {} + } + } + + fn display_message(&mut self, message: &str, message_type: MessageType) { + match message_type { + MessageType::Error => { + if self.status == Status::BatchInProgress { + // Sequential batch mode + self.batch_failed += 1; + self.check_batch_complete(); + if self.status == Status::BatchInProgress { + self.queue_next_sequential(); + } + } else { + self.status = Status::NotStarted; + self.error_message = Some(message.to_string()); + } + } + _ => { + self.success_message = Some(message.to_string()); + } + } + } +} diff --git a/src/ui/wallets/shield_from_asset_lock_screen.rs b/src/ui/wallets/shield_from_asset_lock_screen.rs new file mode 100644 index 000000000..36e275ae0 --- /dev/null +++ b/src/ui/wallets/shield_from_asset_lock_screen.rs @@ -0,0 +1,214 @@ +use crate::app::AppAction; +use crate::backend_task::shielded::ShieldedTask; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; +use crate::context::AppContext; +use crate::model::wallet::WalletSeedHash; +use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::styled::island_central_panel; +use crate::ui::components::top_panel::add_top_panel; +use crate::ui::{MessageType, RootScreenType, ScreenLike}; +use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; +use eframe::egui::{self, Context}; +use egui::{Color32, RichText}; +use std::sync::Arc; + +#[derive(PartialEq)] +enum Status { + NotStarted, + WaitingForResult, + Complete, +} + +pub struct ShieldFromAssetLockScreen { + pub app_context: Arc, + pub seed_hash: WalletSeedHash, + amount_str: String, + core_balance_duffs: u64, + status: Status, + error_message: Option, + success_message: Option, +} + +impl ShieldFromAssetLockScreen { + pub fn new(seed_hash: WalletSeedHash, app_context: &Arc) -> Self { + let core_balance_duffs = { + let wallets = app_context.wallets.read().unwrap(); + wallets + .get(&seed_hash) + .map(|w| { + let wallet = w.read().unwrap(); + wallet.total_balance_duffs() + }) + .unwrap_or(0) + }; + + Self { + app_context: app_context.clone(), + seed_hash, + amount_str: String::new(), + core_balance_duffs, + status: Status::NotStarted, + error_message: None, + success_message: None, + } + } + + /// Parse amount input as DASH (decimal) and return duffs. + fn parse_amount_duffs(&self) -> Option { + let trimmed = self.amount_str.trim(); + if trimmed.is_empty() { + return None; + } + let dash: f64 = trimmed.parse().ok()?; + if dash <= 0.0 { + return None; + } + let duffs = (dash * 1e8) as u64; + if duffs == 0 { + return None; + } + Some(duffs) + } +} + +impl ScreenLike for ShieldFromAssetLockScreen { + fn ui(&mut self, ctx: &Context) -> AppAction { + let mut action = add_top_panel( + ctx, + &self.app_context, + vec![ + ("Wallets", AppAction::PopScreen), + ("Shield from Core", AppAction::None), + ], + vec![], + ); + + action |= add_left_panel( + ctx, + &self.app_context, + RootScreenType::RootScreenWalletsBalances, + ); + + island_central_panel(ctx, |ui| { + ui.heading("Shield from Core Wallet"); + ui.add_space(10.0); + ui.label("Send core DASH directly into the shielded pool via an asset lock."); + ui.add_space(5.0); + + let dash_balance = self.core_balance_duffs as f64 / 1e8; + ui.label(format!( + "Available core wallet balance: {:.8} DASH", + dash_balance + )); + ui.add_space(15.0); + + // Error/success messages + if let Some(err) = &self.error_message { + ui.colored_label(Color32::from_rgb(255, 100, 100), err); + ui.add_space(5.0); + } + if let Some(msg) = &self.success_message { + ui.colored_label(Color32::DARK_GREEN, msg); + ui.add_space(10.0); + if ui.button("Done").clicked() { + action = AppAction::PopScreen; + } + return; + } + + // Amount input + ui.horizontal(|ui| { + ui.label("Amount (DASH):"); + ui.text_edit_singleline(&mut self.amount_str); + }); + if let Some(duffs) = self.parse_amount_duffs() { + let credits = duffs * CREDITS_PER_DUFF; + let dash = duffs as f64 / 1e8; + ui.label(format!( + "= {:.8} DASH = {} credits on platform", + dash, credits + )); + if duffs > self.core_balance_duffs { + ui.colored_label( + Color32::from_rgb(255, 100, 100), + "Exceeds core wallet balance", + ); + } + } + ui.add_space(15.0); + + // Confirm + let amount_ok = self + .parse_amount_duffs() + .is_some_and(|a| a <= self.core_balance_duffs); + let can_confirm = self.status == Status::NotStarted && amount_ok; + + if self.status == Status::WaitingForResult { + ui.horizontal(|ui| { + ui.add(egui::Spinner::new()); + ui.label("Creating asset lock and shielding... (this may take a few minutes)"); + }); + } else { + ui.horizontal(|ui| { + if ui + .add_enabled( + can_confirm, + egui::Button::new( + RichText::new("Shield from Core") + .color(Color32::WHITE) + .size(16.0), + ) + .fill(crate::ui::theme::DashColors::DASH_BLUE), + ) + .clicked() + && let Some(amount_duffs) = self.parse_amount_duffs() + { + self.status = Status::WaitingForResult; + self.error_message = None; + action = AppAction::BackendTask(BackendTask::ShieldedTask( + ShieldedTask::ShieldFromAssetLock { + seed_hash: self.seed_hash, + amount_duffs, + }, + )); + } + + ui.add_space(10.0); + if ui.button("Cancel").clicked() { + action = AppAction::PopScreen; + } + }); + } + }); + + action + } + + fn display_task_result(&mut self, result: BackendTaskSuccessResult) { + match result { + BackendTaskSuccessResult::ShieldedFromAssetLock { seed_hash, amount } + if seed_hash == self.seed_hash => + { + self.status = Status::Complete; + let dash = amount as f64 / CREDITS_PER_DUFF as f64 / 1e8; + self.success_message = Some(format!( + "Successfully shielded {:.8} DASH from core wallet", + dash + )); + } + _ => {} + } + } + + fn display_message(&mut self, message: &str, message_type: MessageType) { + match message_type { + MessageType::Error => { + self.status = Status::NotStarted; + self.error_message = Some(message.to_string()); + } + _ => { + self.success_message = Some(message.to_string()); + } + } + } +} diff --git a/src/ui/wallets/shielded_send_screen.rs b/src/ui/wallets/shielded_send_screen.rs new file mode 100644 index 000000000..ae8c26006 --- /dev/null +++ b/src/ui/wallets/shielded_send_screen.rs @@ -0,0 +1,238 @@ +use crate::app::AppAction; +use crate::backend_task::shielded::ShieldedTask; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; +use crate::context::AppContext; +use crate::model::wallet::WalletSeedHash; +use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::styled::island_central_panel; +use crate::ui::components::top_panel::add_top_panel; +use crate::ui::{MessageType, RootScreenType, ScreenLike}; +use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; +use eframe::egui::{self, Context}; +use egui::{Color32, RichText}; +use std::sync::Arc; + +#[derive(PartialEq)] +enum Status { + NotStarted, + WaitingForResult, + Complete, +} + +pub struct ShieldedSendScreen { + pub app_context: Arc, + pub seed_hash: WalletSeedHash, + amount_str: String, + recipient_address_input: String, + max_balance: u64, + status: Status, + error_message: Option, + success_message: Option, +} + +impl ShieldedSendScreen { + pub fn new(seed_hash: WalletSeedHash, app_context: &Arc) -> Self { + let max_balance = { + let states = app_context.shielded_states.lock().unwrap(); + states + .get(&seed_hash) + .map(|s| s.shielded_balance) + .unwrap_or(0) + }; + + Self { + app_context: app_context.clone(), + seed_hash, + amount_str: String::new(), + recipient_address_input: String::new(), + max_balance, + status: Status::NotStarted, + error_message: None, + success_message: None, + } + } + + fn parse_amount_credits(&self) -> Option { + let trimmed = self.amount_str.trim(); + if trimmed.is_empty() { + return None; + } + if trimmed.contains('.') { + let dash: f64 = trimmed.parse().ok()?; + if dash <= 0.0 { + return None; + } + Some((dash * CREDITS_PER_DUFF as f64 * 1e8) as u64) + } else { + let credits: u64 = trimmed.parse().ok()?; + if credits == 0 { + return None; + } + Some(credits) + } + } + + fn validate_recipient(&self) -> Option> { + let trimmed = self.recipient_address_input.trim(); + if trimmed.is_empty() { + return None; + } + // Try bech32m first (dash1z... or tdash1z...) + if let Ok((addr, _network)) = + dash_sdk::dpp::address_funds::OrchardAddress::from_bech32m_string(trimmed) + { + return Some(addr.to_raw_bytes().to_vec()); + } + // Fall back to raw hex (43 bytes = 86 hex chars) + let bytes = hex::decode(trimmed).ok()?; + if bytes.len() != 43 { + return None; + } + Some(bytes) + } +} + +impl ScreenLike for ShieldedSendScreen { + fn ui(&mut self, ctx: &Context) -> AppAction { + let mut action = add_top_panel( + ctx, + &self.app_context, + vec![ + ("Wallets", AppAction::PopScreen), + ("Send (Private)", AppAction::None), + ], + vec![], + ); + + action |= add_left_panel( + ctx, + &self.app_context, + RootScreenType::RootScreenWalletsBalances, + ); + + island_central_panel(ctx, |ui| { + ui.heading("Send (Private)"); + ui.add_space(10.0); + ui.label("Transfer credits privately within the shielded pool."); + ui.add_space(5.0); + + let dash_balance = self.max_balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; + ui.label(format!( + "Available shielded balance: {:.8} DASH", + dash_balance + )); + ui.add_space(15.0); + + // Error/success messages + if let Some(err) = &self.error_message { + ui.colored_label(Color32::from_rgb(255, 100, 100), err); + ui.add_space(5.0); + } + if let Some(msg) = &self.success_message { + ui.colored_label(Color32::DARK_GREEN, msg); + ui.add_space(10.0); + if ui.button("Done").clicked() { + action = AppAction::PopScreen; + } + return; + } + + // Recipient address input + ui.label("Recipient shielded address (dash1z.../tdash1z... or hex):"); + ui.add_space(2.0); + ui.text_edit_singleline(&mut self.recipient_address_input); + if !self.recipient_address_input.trim().is_empty() + && self.validate_recipient().is_none() + { + ui.colored_label(Color32::from_rgb(255, 100, 100), "Invalid shielded address"); + } + ui.add_space(10.0); + + // Amount input + ui.horizontal(|ui| { + ui.label("Amount (DASH or credits):"); + ui.text_edit_singleline(&mut self.amount_str); + }); + if let Some(credits) = self.parse_amount_credits() { + let dash = credits as f64 / CREDITS_PER_DUFF as f64 / 1e8; + ui.label(format!("= {:.8} DASH ({} credits)", dash, credits)); + if credits > self.max_balance { + ui.colored_label(Color32::from_rgb(255, 100, 100), "Exceeds shielded balance"); + } + } + ui.add_space(15.0); + + // Confirm + let amount_ok = self + .parse_amount_credits() + .is_some_and(|a| a <= self.max_balance); + let recipient_ok = self.validate_recipient().is_some(); + let can_confirm = self.status == Status::NotStarted && amount_ok && recipient_ok; + + if self.status == Status::WaitingForResult { + ui.horizontal(|ui| { + ui.add(egui::Spinner::new()); + ui.label("Sending privately..."); + }); + } else { + ui.horizontal(|ui| { + if ui + .add_enabled( + can_confirm, + egui::Button::new( + RichText::new("Send").color(Color32::WHITE).size(16.0), + ) + .fill(crate::ui::theme::DashColors::DASH_BLUE), + ) + .clicked() + && let (Some(amount), Some(recipient_bytes)) = + (self.parse_amount_credits(), self.validate_recipient()) + { + self.status = Status::WaitingForResult; + self.error_message = None; + action = AppAction::BackendTask(BackendTask::ShieldedTask( + ShieldedTask::ShieldedTransfer { + seed_hash: self.seed_hash, + amount, + recipient_address_bytes: recipient_bytes, + }, + )); + } + + ui.add_space(10.0); + if ui.button("Cancel").clicked() { + action = AppAction::PopScreen; + } + }); + } + }); + + action + } + + fn display_task_result(&mut self, result: BackendTaskSuccessResult) { + match result { + BackendTaskSuccessResult::ShieldedTransferComplete { seed_hash, amount } + if seed_hash == self.seed_hash => + { + self.status = Status::Complete; + let dash = amount as f64 / CREDITS_PER_DUFF as f64 / 1e8; + self.success_message = + Some(format!("Successfully sent {:.8} DASH privately", dash)); + } + _ => {} + } + } + + fn display_message(&mut self, message: &str, message_type: MessageType) { + match message_type { + MessageType::Error => { + self.status = Status::NotStarted; + self.error_message = Some(message.to_string()); + } + _ => { + self.success_message = Some(message.to_string()); + } + } + } +} diff --git a/src/ui/wallets/shielded_tab.rs b/src/ui/wallets/shielded_tab.rs new file mode 100644 index 000000000..c7631c1b0 --- /dev/null +++ b/src/ui/wallets/shielded_tab.rs @@ -0,0 +1,624 @@ +use crate::app::AppAction; +use crate::backend_task::BackendTask; +use crate::backend_task::shielded::ShieldedTask; +use crate::context::AppContext; +use crate::model::wallet::WalletSeedHash; +use crate::ui::ScreenType; +use crate::ui::components::wallet_unlock_popup::{ + WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, +}; +use crate::ui::helpers::copy_text_to_clipboard; +use crate::ui::theme::DashColors; +use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; +use eframe::egui::{self, Ui}; +use egui::{Color32, Frame, Margin, RichText}; +use std::sync::Arc; + +/// View component for the Shielded tab within the Wallets screen. +pub struct ShieldedTabView { + app_context: Arc, + seed_hash: WalletSeedHash, + initializing: bool, + syncing: bool, + error_message: Option, + success_message: Option, + shielded_balance: u64, + is_initialized: bool, + /// Whether the commitment tree has been synced (enables spend operations). + tree_synced: bool, + /// Pending backend task to dispatch on next ui() call (e.g., auto-sync after init). + pending_task: Option, + /// Wallet unlock popup for the initialize flow. + wallet_unlock_popup: WalletUnlockPopup, + /// Currently selected diversified address index. + selected_address_index: u32, + /// Number of diversified addresses generated (always >= 1). + address_count: u32, +} + +impl ShieldedTabView { + pub fn new(app_context: &Arc, seed_hash: WalletSeedHash) -> Self { + Self { + app_context: app_context.clone(), + seed_hash, + initializing: false, + syncing: false, + error_message: None, + success_message: None, + shielded_balance: 0, + is_initialized: false, + tree_synced: false, + pending_task: None, + wallet_unlock_popup: WalletUnlockPopup::new(), + selected_address_index: 0, + address_count: 1, + } + } + + pub fn is_syncing(&self) -> bool { + self.syncing + } + + pub fn update_seed_hash(&mut self, seed_hash: WalletSeedHash) { + if self.seed_hash != seed_hash { + self.seed_hash = seed_hash; + self.is_initialized = false; + self.tree_synced = false; + self.shielded_balance = 0; + self.error_message = None; + self.success_message = None; + } + } + + pub fn update_app_context(&mut self, app_context: &Arc) { + self.app_context = app_context.clone(); + } + + /// Handle backend task results for shielded operations. + pub fn handle_result( + &mut self, + result: &crate::backend_task::BackendTaskSuccessResult, + ) -> bool { + use crate::backend_task::BackendTaskSuccessResult; + match result { + BackendTaskSuccessResult::ShieldedInitialized { seed_hash, balance } + if *seed_hash == self.seed_hash => + { + self.initializing = false; + self.is_initialized = true; + self.shielded_balance = *balance; + // Auto-sync notes after initialization + self.syncing = true; + self.pending_task = Some(BackendTask::ShieldedTask(ShieldedTask::SyncNotes { + seed_hash: self.seed_hash, + })); + true + } + BackendTaskSuccessResult::ShieldedNotesSynced { + seed_hash, + new_notes, + balance, + } if *seed_hash == self.seed_hash => { + self.tree_synced = true; + self.shielded_balance = *balance; + if *new_notes > 0 { + self.success_message = Some(format!("Synced {} new note(s)", new_notes)); + } + // Auto-check nullifiers after sync to detect spent notes + self.pending_task = + Some(BackendTask::ShieldedTask(ShieldedTask::CheckNullifiers { + seed_hash: self.seed_hash, + })); + true + } + BackendTaskSuccessResult::ShieldedCreditsShielded { seed_hash, amount } + if *seed_hash == self.seed_hash => + { + self.success_message = + Some(format!("Shielded {} successfully", format_credits(*amount))); + true + } + BackendTaskSuccessResult::ShieldedTransferComplete { seed_hash, amount } + if *seed_hash == self.seed_hash => + { + self.success_message = + Some(format!("Transferred {} privately", format_credits(*amount))); + true + } + BackendTaskSuccessResult::ShieldedCreditsUnshielded { seed_hash, amount } + if *seed_hash == self.seed_hash => + { + self.success_message = Some(format!("Unshielded {}", format_credits(*amount))); + true + } + BackendTaskSuccessResult::ShieldedFromAssetLock { seed_hash, amount } + if *seed_hash == self.seed_hash => + { + self.success_message = Some(format!( + "Shielded {} from core wallet", + format_credits(*amount) + )); + true + } + BackendTaskSuccessResult::ShieldedNullifiersChecked { + seed_hash, + spent_count, + } if *seed_hash == self.seed_hash => { + self.syncing = false; + // Update balance from state after nullifier check + let states = self.app_context.shielded_states.lock().unwrap(); + if let Some(state) = states.get(&self.seed_hash) { + self.shielded_balance = state.shielded_balance; + } + drop(states); + if *spent_count > 0 { + self.success_message = Some(format!("Detected {} spent note(s)", spent_count)); + } + true + } + _ => false, + } + } + + pub fn handle_error(&mut self, error: &str) { + self.syncing = false; + self.initializing = false; + self.error_message = Some(error.to_string()); + } + + /// Render the shielded tab content. + pub fn ui(&mut self, ui: &mut Ui) -> AppAction { + let dark_mode = ui.ctx().style().visuals.dark_mode; + let mut action = self + .pending_task + .take() + .map(AppAction::BackendTask) + .unwrap_or(AppAction::None); + + // Messages + if let Some(err) = &self.error_message.clone() { + Frame::new() + .fill(Color32::from_rgb(255, 100, 100).gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(err).color(Color32::from_rgb(255, 100, 100))); + if ui.small_button("Dismiss").clicked() { + self.error_message = None; + } + }); + }); + ui.add_space(5.0); + } + + if let Some(msg) = &self.success_message.clone() { + Frame::new() + .fill(Color32::DARK_GREEN.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(msg).color(Color32::DARK_GREEN)); + if ui.small_button("Dismiss").clicked() { + self.success_message = None; + } + }); + }); + ui.add_space(5.0); + } + + // --- Not yet initialized --- + // Auto-initialize if the wallet is already open (no user click needed) + if !self.is_initialized && !self.initializing { + let wallet_arc = { + let wallets = self.app_context.wallets.read().unwrap(); + wallets.get(&self.seed_hash).cloned() + }; + if let Some(wallet) = &wallet_arc + && !wallet_needs_unlock(wallet) + { + let _ = try_open_wallet_no_password(wallet); + self.initializing = true; + action |= AppAction::BackendTask(BackendTask::ShieldedTask( + ShieldedTask::InitializeShieldedWallet { + seed_hash: self.seed_hash, + }, + )); + } + } + + if !self.is_initialized { + if self.initializing { + ui.horizontal(|ui| { + ui.add(egui::Spinner::new().color(DashColors::DASH_BLUE)); + ui.label("Initializing shielded wallet (deriving ZIP32 keys)..."); + }); + } else { + ui.add_space(20.0); + ui.label( + RichText::new( + "Initialize your shielded wallet to enable private transactions.", + ) + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(10.0); + + let init_btn = egui::Button::new( + RichText::new("Initialize Shielded Wallet") + .color(Color32::WHITE) + .size(16.0), + ) + .fill(DashColors::DASH_BLUE); + + if ui.add(init_btn).clicked() { + // Get the wallet Arc + let wallet_arc = { + let wallets = self.app_context.wallets.read().unwrap(); + wallets.get(&self.seed_hash).cloned() + }; + + if let Some(wallet) = &wallet_arc { + if wallet_needs_unlock(wallet) { + // Wallet is locked — open unlock popup + self.wallet_unlock_popup.open(); + } else { + // Try open without password (for passwordless wallets) + let _ = try_open_wallet_no_password(wallet); + // Proceed to initialize + self.initializing = true; + action |= AppAction::BackendTask(BackendTask::ShieldedTask( + ShieldedTask::InitializeShieldedWallet { + seed_hash: self.seed_hash, + }, + )); + } + } + } + } + + // Show unlock popup if open + if self.wallet_unlock_popup.is_open() { + let wallet_arc = { + let wallets = self.app_context.wallets.read().unwrap(); + wallets.get(&self.seed_hash).cloned() + }; + + if let Some(wallet) = &wallet_arc { + let unlock_result = + self.wallet_unlock_popup + .show(ui.ctx(), wallet, &self.app_context); + match unlock_result { + WalletUnlockResult::Unlocked => { + // Wallet is now open — proceed to initialize + self.initializing = true; + action |= AppAction::BackendTask(BackendTask::ShieldedTask( + ShieldedTask::InitializeShieldedWallet { + seed_hash: self.seed_hash, + }, + )); + } + WalletUnlockResult::Cancelled => { + // User cancelled — do nothing + } + WalletUnlockResult::Pending => { + // Still showing popup + } + } + } + } + + return action; + } + + // --- Initialized: show balance, address, actions --- + + // Balance display + ui.add_space(10.0); + Frame::new() + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(16, 12)) + .corner_radius(8.0) + .show(ui, |ui| { + ui.label( + RichText::new("Shielded Balance") + .size(16.0) + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label( + RichText::new(format_credits(self.shielded_balance)) + .size(28.0) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + }); + }); + + ui.add_space(10.0); + + // Payment address (bech32m encoded: dash1z... or tdash1z...) + let address_str = { + let states = self.app_context.shielded_states.lock().unwrap(); + states.get(&self.seed_hash).and_then(|state| { + use dash_sdk::dpp::address_funds::OrchardAddress; + use dash_sdk::grovedb_commitment_tree::Scope; + let addr = state + .keys + .fvk + .address_at(self.selected_address_index, Scope::External); + let raw = addr.to_raw_address_bytes(); + let orchard_addr = OrchardAddress::from_raw_bytes(&raw).ok()?; + Some(orchard_addr.to_bech32m_string(self.app_context.network)) + }) + }; + + if let Some(addr) = &address_str { + Frame::new() + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(16, 12)) + .corner_radius(8.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new(format!( + "Shielded Payment Address ({})", + self.selected_address_index + )) + .size(14.0) + .color(DashColors::text_secondary(dark_mode)), + ); + + // Address selector: prev/next arrows + if self.selected_address_index > 0 && ui.small_button("<").clicked() { + self.selected_address_index -= 1; + } + if self.selected_address_index + 1 < self.address_count + && ui.small_button(">").clicked() + { + self.selected_address_index += 1; + } + + // Generate new diversified address + if ui + .small_button("+") + .on_hover_text("Generate new diversified address") + .clicked() + { + self.selected_address_index = self.address_count; + self.address_count += 1; + } + }); + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.monospace(addr); + if ui.small_button("Copy").clicked() { + let _ = copy_text_to_clipboard(addr); + } + }); + }); + } + + ui.add_space(10.0); + + // Action buttons + ui.horizontal(|ui| { + let shield_btn = + egui::Button::new(RichText::new("Shield").color(Color32::WHITE).size(14.0)) + .fill(DashColors::DASH_BLUE); + if ui + .add_enabled(!self.syncing, shield_btn) + .on_hover_text("Shield credits from platform address into the shielded pool") + .clicked() + { + action |= AppAction::AddScreen( + ScreenType::ShieldCreditsScreen(self.seed_hash) + .create_screen(&self.app_context), + ); + } + + let shield_core_btn = egui::Button::new( + RichText::new("Shield from Core") + .color(Color32::WHITE) + .size(14.0), + ) + .fill(DashColors::DASH_BLUE); + if ui + .add_enabled(!self.syncing, shield_core_btn) + .on_hover_text("Shield core DASH directly into the shielded pool via asset lock") + .clicked() + { + action |= AppAction::AddScreen( + ScreenType::ShieldFromAssetLockScreen(self.seed_hash) + .create_screen(&self.app_context), + ); + } + + let can_spend = !self.syncing && self.tree_synced && self.shielded_balance > 0; + + let send_btn = egui::Button::new( + RichText::new("Send (Private)") + .color(Color32::WHITE) + .size(14.0), + ) + .fill(DashColors::DASH_BLUE); + if ui + .add_enabled(can_spend, send_btn) + .on_hover_text(if self.tree_synced { + "Transfer privately within the shielded pool" + } else { + "Sync notes first to enable spending" + }) + .clicked() + { + action |= AppAction::AddScreen( + ScreenType::ShieldedSendScreen(self.seed_hash).create_screen(&self.app_context), + ); + } + + let unshield_btn = + egui::Button::new(RichText::new("Unshield").color(Color32::WHITE).size(14.0)) + .fill(DashColors::DASH_BLUE); + if ui + .add_enabled(can_spend, unshield_btn) + .on_hover_text(if self.tree_synced { + "Unshield credits to a platform address" + } else { + "Sync notes first to enable spending" + }) + .clicked() + { + action |= AppAction::AddScreen( + ScreenType::UnshieldCreditsScreen(self.seed_hash) + .create_screen(&self.app_context), + ); + } + }); + + ui.add_space(15.0); + + // Notes section header with sync status and buttons + let (notes_info, synced_index): (Vec<(u64, u64, bool)>, u64) = { + let states = self.app_context.shielded_states.lock().unwrap(); + states + .get(&self.seed_hash) + .map(|state| { + let notes = state + .notes + .iter() + .map(|n| (n.value, n.block_height, n.is_spent)) + .collect(); + (notes, state.last_synced_index) + }) + .unwrap_or_default() + }; + + ui.horizontal(|ui| { + ui.label( + RichText::new("Shielded Notes") + .size(16.0) + .color(DashColors::text_primary(dark_mode)), + ); + + if !notes_info.is_empty() { + ui.label( + RichText::new(format!( + "(synced to index {}, {} our notes)", + synced_index, + notes_info.len() + )) + .size(12.0) + .color(DashColors::text_secondary(dark_mode)), + ); + } + + // Sync status indicator + if self.syncing { + ui.add(egui::Spinner::new().color(DashColors::DASH_BLUE)); + ui.label( + RichText::new("Syncing...") + .size(12.0) + .color(DashColors::DASH_BLUE), + ); + } else if self.tree_synced { + ui.label( + RichText::new("Synced") + .size(12.0) + .color(Color32::DARK_GREEN), + ); + } + + // Sync buttons + if !self.syncing { + if ui.small_button("Sync Notes").clicked() { + self.syncing = true; + self.success_message = None; + self.error_message = None; + action |= AppAction::BackendTask(BackendTask::ShieldedTask( + ShieldedTask::SyncNotes { + seed_hash: self.seed_hash, + }, + )); + } + + if self.app_context.is_developer_mode() && ui.small_button("Resync Notes").clicked() + { + // Remove in-memory state entirely (will be recreated by init) + { + let mut states = self.app_context.shielded_states.lock().unwrap(); + states.remove(&self.seed_hash); + } + // Clear persisted notes and commitment tree data + let network_str = self.app_context.network.to_string(); + let _ = self + .app_context + .db + .delete_shielded_notes(&self.seed_hash, &network_str); + let _ = self.app_context.db.clear_commitment_tree_tables(); + + self.shielded_balance = 0; + self.tree_synced = false; + self.is_initialized = false; + self.initializing = true; + self.syncing = false; + self.success_message = None; + self.error_message = None; + // Re-initialize (creates fresh persistent tree) then auto-syncs + action |= AppAction::BackendTask(BackendTask::ShieldedTask( + ShieldedTask::InitializeShieldedWallet { + seed_hash: self.seed_hash, + }, + )); + } + } + }); + ui.add_space(5.0); + + if !notes_info.is_empty() { + egui::Grid::new("shielded_notes_grid") + .num_columns(3) + .striped(true) + .spacing([20.0, 4.0]) + .show(ui, |ui| { + ui.label(RichText::new("Value").strong()); + ui.label(RichText::new("Block").strong()); + ui.label(RichText::new("Status").strong()); + ui.end_row(); + + for (value, height, is_spent) in ¬es_info { + ui.label(format_credits(*value)); + ui.label(if *height > 0 { + height.to_string() + } else { + "-".to_string() + }); + if *is_spent { + ui.label( + RichText::new("Spent").color(DashColors::text_secondary(dark_mode)), + ); + } else { + ui.label(RichText::new("Unspent").color(Color32::DARK_GREEN)); + } + ui.end_row(); + } + }); + } else if !self.syncing { + ui.label( + RichText::new("No shielded notes yet. Shield some credits to get started.") + .color(DashColors::text_secondary(dark_mode)), + ); + } + + action + } +} + +fn format_credits(credits: u64) -> String { + let dash = credits as f64 / CREDITS_PER_DUFF as f64 / 1e8; + if dash >= 0.01 { + format!("{:.4} DASH", dash) + } else { + format!("{} credits", credits) + } +} diff --git a/src/ui/wallets/unshield_credits_screen.rs b/src/ui/wallets/unshield_credits_screen.rs new file mode 100644 index 000000000..749b320ad --- /dev/null +++ b/src/ui/wallets/unshield_credits_screen.rs @@ -0,0 +1,307 @@ +use crate::app::AppAction; +use crate::backend_task::shielded::ShieldedTask; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; +use crate::context::AppContext; +use crate::model::wallet::WalletSeedHash; +use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::styled::island_central_panel; +use crate::ui::components::top_panel::add_top_panel; +use crate::ui::{MessageType, RootScreenType, ScreenLike}; +use dash_sdk::dpp::address_funds::PlatformAddress; +use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; +use dash_sdk::dpp::dashcore::Address; +use eframe::egui::{self, Context}; +use egui::{Color32, RichText}; +use std::str::FromStr; +use std::sync::Arc; + +#[derive(PartialEq)] +enum Status { + NotStarted, + WaitingForResult, + Complete, +} + +/// Which kind of destination was parsed from the address input. +enum Destination { + /// Shielded pool → platform address (Type 17 Unshield) + Platform(PlatformAddress), + /// Shielded pool → core L1 address (Type 19 ShieldedWithdrawal) + Core(Address), +} + +pub struct UnshieldCreditsScreen { + pub app_context: Arc, + pub seed_hash: WalletSeedHash, + amount_str: String, + address_str: String, + max_balance: u64, + status: Status, + error_message: Option, + success_message: Option, +} + +impl UnshieldCreditsScreen { + pub fn new(seed_hash: WalletSeedHash, app_context: &Arc) -> Self { + let max_balance = { + let states = app_context.shielded_states.lock().unwrap(); + states + .get(&seed_hash) + .map(|s| s.shielded_balance) + .unwrap_or(0) + }; + + Self { + app_context: app_context.clone(), + seed_hash, + amount_str: String::new(), + address_str: String::new(), + max_balance, + status: Status::NotStarted, + error_message: None, + success_message: None, + } + } + + fn parse_amount_credits(&self) -> Option { + let trimmed = self.amount_str.trim(); + if trimmed.is_empty() { + return None; + } + if trimmed.contains('.') { + let dash: f64 = trimmed.parse().ok()?; + if dash <= 0.0 { + return None; + } + Some((dash * CREDITS_PER_DUFF as f64 * 1e8) as u64) + } else { + let credits: u64 = trimmed.parse().ok()?; + if credits == 0 { + return None; + } + Some(credits) + } + } + + /// Parse the address field into a Destination. + /// + /// Tries platform address (Bech32m tdash1.../dash1...) first, then falls + /// back to a core address (Base58 P2PKH/P2SH). + fn parse_destination(&self) -> Option { + let s = self.address_str.trim(); + if s.is_empty() { + return None; + } + + // Try platform address first + if let Ok((pa, _network)) = PlatformAddress::from_bech32m_string(s) { + return Some(Destination::Platform(pa)); + } + + // Try core address + if let Ok(addr) = Address::from_str(s) { + let addr = addr.require_network(self.app_context.network).ok()?; + return Some(Destination::Core(addr)); + } + + None + } +} + +impl ScreenLike for UnshieldCreditsScreen { + fn ui(&mut self, ctx: &Context) -> AppAction { + let mut action = add_top_panel( + ctx, + &self.app_context, + vec![ + ("Wallets", AppAction::PopScreen), + ("Unshield Credits", AppAction::None), + ], + vec![], + ); + + action |= add_left_panel( + ctx, + &self.app_context, + RootScreenType::RootScreenWalletsBalances, + ); + + island_central_panel(ctx, |ui| { + ui.heading("Unshield Credits"); + ui.add_space(10.0); + ui.label( + "Move credits from the shielded pool to a platform address or a core DASH address.", + ); + ui.add_space(5.0); + + let dash_balance = self.max_balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; + ui.label(format!( + "Available shielded balance: {:.8} DASH", + dash_balance + )); + ui.add_space(15.0); + + // Error/success messages + if let Some(err) = &self.error_message { + ui.colored_label(Color32::from_rgb(255, 100, 100), err); + ui.add_space(5.0); + } + if let Some(msg) = &self.success_message { + ui.colored_label(Color32::DARK_GREEN, msg); + ui.add_space(10.0); + if ui.button("Done").clicked() { + action = AppAction::PopScreen; + } + return; + } + + // Destination address input + ui.horizontal(|ui| { + ui.label("To address:"); + ui.text_edit_singleline(&mut self.address_str); + }); + + // Show what was parsed + match self.parse_destination() { + Some(Destination::Platform(_)) => { + ui.colored_label( + Color32::DARK_GREEN, + "Platform address — will unshield to platform (Type 17)", + ); + } + Some(Destination::Core(_)) => { + ui.colored_label( + Color32::DARK_GREEN, + "Core address — will withdraw to core DASH (Type 19)", + ); + } + None if !self.address_str.trim().is_empty() => { + ui.colored_label( + Color32::from_rgb(255, 100, 100), + "Unrecognised address — enter a platform address (tdash1…/dash1…) or a core DASH address", + ); + } + None => {} + } + ui.add_space(10.0); + + // Amount input + ui.horizontal(|ui| { + ui.label("Amount (DASH):"); + ui.text_edit_singleline(&mut self.amount_str); + }); + if let Some(credits) = self.parse_amount_credits() { + let dash = credits as f64 / CREDITS_PER_DUFF as f64 / 1e8; + ui.label(format!("= {:.8} DASH ({} credits)", dash, credits)); + if credits > self.max_balance { + ui.colored_label(Color32::from_rgb(255, 100, 100), "Exceeds shielded balance"); + } + } + ui.add_space(15.0); + + let amount_ok = self + .parse_amount_credits() + .is_some_and(|a| a <= self.max_balance); + let destination = self.parse_destination(); + let can_confirm = + self.status == Status::NotStarted && amount_ok && destination.is_some(); + + if self.status == Status::WaitingForResult { + ui.horizontal(|ui| { + ui.add(egui::Spinner::new()); + ui.label("Processing..."); + }); + } else { + ui.horizontal(|ui| { + let btn_label = match &destination { + Some(Destination::Core(_)) => "Withdraw to Core", + _ => "Unshield", + }; + + if ui + .add_enabled( + can_confirm, + egui::Button::new( + RichText::new(btn_label).color(Color32::WHITE).size(16.0), + ) + .fill(crate::ui::theme::DashColors::DASH_BLUE), + ) + .clicked() + && let Some(amount) = self.parse_amount_credits() + { + match self.parse_destination() { + Some(Destination::Platform(addr)) => { + self.status = Status::WaitingForResult; + self.error_message = None; + action = AppAction::BackendTask(BackendTask::ShieldedTask( + ShieldedTask::UnshieldCredits { + seed_hash: self.seed_hash, + amount, + to_platform_address: addr, + }, + )); + } + Some(Destination::Core(addr)) => { + self.status = Status::WaitingForResult; + self.error_message = None; + action = AppAction::BackendTask(BackendTask::ShieldedTask( + ShieldedTask::ShieldedWithdrawal { + seed_hash: self.seed_hash, + amount, + to_core_address: addr, + }, + )); + } + None => {} + } + } + + ui.add_space(10.0); + if ui.button("Cancel").clicked() { + action = AppAction::PopScreen; + } + }); + } + }); + + action + } + + fn display_task_result(&mut self, result: BackendTaskSuccessResult) { + match result { + BackendTaskSuccessResult::ShieldedCreditsUnshielded { seed_hash, amount } + if seed_hash == self.seed_hash => + { + self.status = Status::Complete; + let dash = amount as f64 / CREDITS_PER_DUFF as f64 / 1e8; + self.success_message = Some(format!( + "Successfully unshielded {:.8} DASH to platform address", + dash + )); + } + BackendTaskSuccessResult::ShieldedWithdrawalComplete { seed_hash, amount } + if seed_hash == self.seed_hash => + { + self.status = Status::Complete; + let dash = amount as f64 / CREDITS_PER_DUFF as f64 / 1e8; + self.success_message = Some(format!( + "Successfully withdrew {:.8} DASH to core address", + dash + )); + } + _ => {} + } + } + + fn display_message(&mut self, message: &str, message_type: MessageType) { + match message_type { + MessageType::Error => { + self.status = Status::NotStarted; + self.error_message = Some(message.to_string()); + } + _ => { + self.success_message = Some(message.to_string()); + } + } + } +} diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 123e4c8d0..9e208d942 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -37,12 +37,21 @@ use egui_extras::{Column, TableBuilder}; use std::sync::{Arc, RwLock}; use crate::model::wallet::single_key::SingleKeyWallet; +use crate::ui::wallets::shielded_tab::ShieldedTabView; use address_table::{SortColumn, SortOrder}; use dialogs::{ FundPlatformAddressDialogState, MineDialogState, PrivateKeyDialogState, ReceiveDialogState, SendDialogState, }; +/// Tab selector for the wallet detail panel. +#[derive(Default, Clone, Copy, PartialEq)] +enum WalletViewTab { + #[default] + Balances, + Shielded, +} + /// Refresh mode for dev mode dropdown - controls what gets refreshed #[derive(Clone, Copy, PartialEq, Eq, Default)] enum RefreshMode { @@ -109,6 +118,10 @@ pub struct WalletsBalancesScreen { utxo_page: usize, /// Selected refresh mode (only shown in dev mode) refresh_mode: RefreshMode, + /// Currently selected tab in the wallet detail panel + selected_tab: WalletViewTab, + /// Shielded tab view component (lazily initialized per wallet) + shielded_tab_view: Option, /// Cached platform sync info: (last_sync_timestamp, last_sync_height) platform_sync_info: Option<(u64, u64)>, /// Core wallet selection dialog (shown when auto-detection fails) @@ -217,6 +230,8 @@ impl WalletsBalancesScreen { asset_lock_search_banner: None, utxo_page: 0, refresh_mode: RefreshMode::default(), + selected_tab: WalletViewTab::default(), + shielded_tab_view: None, platform_sync_info, core_wallet_dialog: None, pending_core_wallet_seed_hash: None, @@ -323,6 +338,8 @@ impl WalletsBalancesScreen { self.selected_wallet = wallet; self.selected_single_key_wallet = None; self.selected_account = None; + self.selected_tab = WalletViewTab::default(); + self.shielded_tab_view = None; if let Some(hash) = seed_hash { self.persist_selected_wallet_hash(Some(hash)); @@ -911,6 +928,11 @@ impl WalletsBalancesScreen { Amount::dash_from_duffs(amount_duffs).to_string() } + /// Format a `std::time::Instant` as a relative "time ago" string. + fn format_instant_ago(instant: std::time::Instant) -> String { + Self::format_duration_ago(instant.elapsed()) + } + /// Format a Unix timestamp (seconds since epoch) as a relative "time ago" string. fn format_unix_time_ago(unix_ts: u64) -> String { let now = std::time::SystemTime::now() @@ -1263,6 +1285,7 @@ impl WalletsBalancesScreen { }); } + /// Render a compact sync status panel showing Core, Platform, and Shielded sync progress. fn render_sync_status(&self, ui: &mut Ui) { let dark_mode = ui.ctx().style().visuals.dark_mode; @@ -1409,6 +1432,111 @@ impl WalletsBalancesScreen { DashColors::text_secondary(dark_mode) }, )); + + ui.label( + RichText::new("|") + .size(12.0) + .color(DashColors::text_secondary(dark_mode)), + ); + + // Shielded notes + nullifiers + let seed_hash = self + .selected_wallet + .as_ref() + .and_then(|w| w.read().ok().map(|g| g.seed_hash())); + let shielded_info = seed_hash.and_then(|hash| { + let states = self.app_context.shielded_states.lock().ok()?; + let state = states.get(&hash)?; + Some(( + state.last_synced_index, + state.notes.iter().filter(|n| !n.is_spent).count(), + state.last_nullifier_sync_height, + state.last_notes_synced_at, + state.last_nullifiers_synced_at, + )) + }); + let shielded_syncing = self + .shielded_tab_view + .as_ref() + .is_some_and(|v| v.is_syncing()); + + match shielded_info { + Some(( + synced_index, + note_count, + nf_height, + notes_synced_at, + nf_synced_at, + )) => { + if shielded_syncing { + ui.add( + egui::Spinner::new() + .size(12.0) + .color(DashColors::DASH_BLUE), + ); + } + let notes_text = if let Some(t) = notes_synced_at { + let ago = Self::format_instant_ago(t); + format!( + "Notes: {} synced ({} notes, {})", + synced_index, note_count, ago + ) + } else if synced_index > 0 { + format!( + "Notes: {} synced ({} notes)", + synced_index, note_count + ) + } else { + "Notes: never synced".to_string() + }; + ui.label(RichText::new(notes_text).size(12.0).color( + if shielded_syncing { + DashColors::DASH_BLUE + } else { + DashColors::text_secondary(dark_mode) + }, + )); + + ui.label( + RichText::new("|") + .size(12.0) + .color(DashColors::text_secondary(dark_mode)), + ); + + let nf_text = if let Some(t) = nf_synced_at { + let ago = Self::format_instant_ago(t); + format!("Nullifiers: height {} ({})", nf_height, ago) + } else if nf_height > 0 { + format!("Nullifiers: height {}", nf_height) + } else { + "Nullifiers: never synced".to_string() + }; + ui.label(RichText::new(nf_text).size(12.0).color( + if shielded_syncing { + DashColors::DASH_BLUE + } else { + DashColors::text_secondary(dark_mode) + }, + )); + } + None => { + ui.label( + RichText::new("Notes: never synced") + .size(12.0) + .color(DashColors::text_secondary(dark_mode)), + ); + ui.label( + RichText::new("|") + .size(12.0) + .color(DashColors::text_secondary(dark_mode)), + ); + ui.label( + RichText::new("Nullifiers: never synced") + .size(12.0) + .color(DashColors::text_secondary(dark_mode)), + ); + } + } }); }); }, @@ -1462,54 +1590,102 @@ impl WalletsBalancesScreen { ); }); - let summaries = { - let wallet = wallet_arc.read().unwrap(); - self.render_wallet_overview(ui, &wallet); - collect_account_summaries(&wallet, self.app_context.network) - }; - - self.ensure_account_selection(&summaries); - action |= self.render_action_buttons(ui, ctx); - ui.add_space(10.0); - ui.separator(); - self.render_accounts_section(ui, &summaries); - ui.add_space(10.0); - ui.separator(); - ui.add_space(10.0); - let addresses_heading = self - .selected_account - .as_ref() - .map(|(category, index)| { - format!("Addresses ({})", category.label(*index)) - }) - .unwrap_or_else(|| "Addresses".to_string()); + // Tab bar: Balances | Shielded + ui.add_space(6.0); ui.horizontal(|ui| { - ui.heading( - RichText::new(addresses_heading) - .color(DashColors::text_primary(dark_mode)), - ); - ui.with_layout( - egui::Layout::right_to_left(egui::Align::Center), - |ui| { - ui.checkbox( - &mut self.show_zero_balance_addresses, - "Show zero-balance addresses", - ); - }, - ); - }); - ui.add_space(8.0); - action |= self.render_address_table(ui); + let balances_text = if self.selected_tab == WalletViewTab::Balances { + RichText::new("Balances") + .strong() + .color(DashColors::DASH_BLUE) + } else { + RichText::new("Balances") + .color(DashColors::text_secondary(dark_mode)) + }; + if ui + .selectable_label( + self.selected_tab == WalletViewTab::Balances, + balances_text, + ) + .clicked() + { + self.selected_tab = WalletViewTab::Balances; + } - ui.add_space(10.0); + let shielded_text = if self.selected_tab == WalletViewTab::Shielded { + RichText::new("Shielded") + .strong() + .color(DashColors::DASH_BLUE) + } else { + RichText::new("Shielded") + .color(DashColors::text_secondary(dark_mode)) + }; + if ui + .selectable_label( + self.selected_tab == WalletViewTab::Shielded, + shielded_text, + ) + .clicked() + { + self.selected_tab = WalletViewTab::Shielded; + } + }); ui.separator(); - self.render_transactions_section(ui); + ui.add_space(4.0); + + match self.selected_tab { + WalletViewTab::Balances => { + let summaries = { + let wallet = wallet_arc.read().unwrap(); + self.render_wallet_overview(ui, &wallet); + collect_account_summaries(&wallet, self.app_context.network) + }; - ui.add_space(14.0); - self.render_bottom_options(ui); + self.ensure_account_selection(&summaries); + action |= self.render_action_buttons(ui, ctx); + ui.add_space(10.0); + ui.separator(); + self.render_accounts_section(ui, &summaries); + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + let addresses_heading = self + .selected_account + .as_ref() + .map(|(category, index)| { + format!("Addresses ({})", category.label(*index)) + }) + .unwrap_or_else(|| "Addresses".to_string()); + ui.heading( + RichText::new(addresses_heading) + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(8.0); + action |= self.render_address_table(ui); + + // Transactions section - requires SPV which is dev mode only + if self.app_context.is_developer_mode() { + ui.add_space(10.0); + ui.separator(); + self.render_transactions_section(ui); + } - ui.add_space(16.0); - action |= self.render_wallet_asset_locks(ui); + ui.add_space(14.0); + self.render_bottom_options(ui); + + ui.add_space(16.0); + action |= self.render_wallet_asset_locks(ui); + } + WalletViewTab::Shielded => { + let seed_hash = wallet_arc.read().unwrap().seed_hash(); + let shielded_view = + self.shielded_tab_view.get_or_insert_with(|| { + ShieldedTabView::new(&self.app_context, seed_hash) + }); + shielded_view.update_seed_hash(seed_hash); + shielded_view.update_app_context(&self.app_context); + action |= shielded_view.ui(ui); + } + } }); }); }); @@ -1710,8 +1886,8 @@ impl ScreenLike for WalletsBalancesScreen { ui.add_space(10.0); - // Sync status panel (only for HD wallets, dev mode only) - if self.selected_wallet.is_some() && self.app_context.is_developer_mode() { + // Sync status panel (only for HD wallets) + if self.selected_wallet.is_some() { self.render_sync_status(ui); ui.add_space(6.0); } @@ -2122,6 +2298,11 @@ impl ScreenLike for WalletsBalancesScreen { self.fund_platform_dialog.status = Some(message.to_string()); self.fund_platform_dialog.status_is_error = true; } + + // Forward errors to the shielded tab view so it can reset spinner states + if let Some(shielded_view) = &mut self.shielded_tab_view { + shielded_view.handle_error(message); + } } } @@ -2310,6 +2491,17 @@ impl ScreenLike for WalletsBalancesScreen { MessageType::Success, ); } + // Shielded pool results + result @ (crate::ui::BackendTaskSuccessResult::ShieldedInitialized { .. } + | crate::ui::BackendTaskSuccessResult::ShieldedNotesSynced { .. } + | crate::ui::BackendTaskSuccessResult::ShieldedCreditsShielded { .. } + | crate::ui::BackendTaskSuccessResult::ShieldedTransferComplete { .. } + | crate::ui::BackendTaskSuccessResult::ShieldedCreditsUnshielded { .. } + | crate::ui::BackendTaskSuccessResult::ShieldedNullifiersChecked { .. }) => { + if let Some(shielded_view) = &mut self.shielded_tab_view { + shielded_view.handle_result(&result); + } + } crate::ui::BackendTaskSuccessResult::CoreWalletsList(wallets) => { let wallet_hash = self.pending_list_wallet_hash.take(); let is_single_key = self.pending_list_is_single_key;