diff --git a/CHANGELOG.md b/CHANGELOG.md index 038c6fda6..acd96ca94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -101,6 +101,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **"Max" now matches what your Core wallet can actually send**: pressing + "Max" when shielding DASH, funding a Platform address through the Simple + builder-driven form, sending directly to an identity, or funding an identity + (creating or topping up, from your wallet balance or a received deposit) + could suggest an amount larger than the wallet could actually send, so the + transaction was rejected no matter how you adjusted it. Max and the amount + check now ask the wallet directly what it can send instead of estimating from + an on-screen balance, and both reserve room for the fee. The two derive from + the same wallet answer: if your spendable funds change after that answer, + Max steps back to "Checking the available amount…" and the amount check + waits for a fresh answer instead of accepting an outdated ceiling. The + Advanced manual-input Platform-address flow remains governed by + the Core inputs the user selects rather than this builder ceiling. Funding + from a received deposit is also now capped by what actually arrived at that + deposit address, never by unrelated funds elsewhere in the wallet. While the + check is running, the amount field shows "Checking the available amount…"; + if it fails, "The available amount could not be checked." appears with a + "Retry available amount check" button, and you can still switch to a + different funding method at any point. + - **A key held in the clear is used without asking for a password**: for a key an earlier version had saved in two places, one of them password-protected, using the key could bring up a password prompt even though a copy needing no diff --git a/Cargo.lock b/Cargo.lock index b3ce87a90..d89ffe9ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1877,7 +1877,7 @@ dependencies = [ [[package]] name = "dapi-grpc" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fplatform-wallet-storage-rehydration#debf67bdae8d72ae76aba362c43daafba8a5c21d" +source = "git+https://github.com/dashpay/platform?rev=a18bd1586858ef680124e150caad6a7dc21d0b64#a18bd1586858ef680124e150caad6a7dc21d0b64" dependencies = [ "dash-platform-macros", "futures-core", @@ -1979,7 +1979,7 @@ dependencies = [ [[package]] name = "dash-async" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fplatform-wallet-storage-rehydration#debf67bdae8d72ae76aba362c43daafba8a5c21d" +source = "git+https://github.com/dashpay/platform?rev=a18bd1586858ef680124e150caad6a7dc21d0b64#a18bd1586858ef680124e150caad6a7dc21d0b64" dependencies = [ "thiserror 2.0.18", "tokio", @@ -1989,7 +1989,7 @@ dependencies = [ [[package]] name = "dash-context-provider" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fplatform-wallet-storage-rehydration#debf67bdae8d72ae76aba362c43daafba8a5c21d" +source = "git+https://github.com/dashpay/platform?rev=a18bd1586858ef680124e150caad6a7dc21d0b64#a18bd1586858ef680124e150caad6a7dc21d0b64" dependencies = [ "dash-async", "dpp", @@ -2081,7 +2081,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=18c68d4c10ed349f11358e0a9f7c8c59eab8e098#18c68d4c10ed349f11358e0a9f7c8c59eab8e098" +source = "git+https://github.com/dashpay/rust-dashcore?branch=dash-evo-tool#34f0921ee6881d6c88cbd58caad33a6fe6575f24" dependencies = [ "bincode 2.0.1", "bincode_derive", @@ -2092,7 +2092,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=18c68d4c10ed349f11358e0a9f7c8c59eab8e098#18c68d4c10ed349f11358e0a9f7c8c59eab8e098" +source = "git+https://github.com/dashpay/rust-dashcore?branch=dash-evo-tool#34f0921ee6881d6c88cbd58caad33a6fe6575f24" dependencies = [ "dash-network", ] @@ -2100,7 +2100,7 @@ dependencies = [ [[package]] name = "dash-platform-macros" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fplatform-wallet-storage-rehydration#debf67bdae8d72ae76aba362c43daafba8a5c21d" +source = "git+https://github.com/dashpay/platform?rev=a18bd1586858ef680124e150caad6a7dc21d0b64#a18bd1586858ef680124e150caad6a7dc21d0b64" dependencies = [ "heck", "quote", @@ -2110,7 +2110,7 @@ dependencies = [ [[package]] name = "dash-sdk" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fplatform-wallet-storage-rehydration#debf67bdae8d72ae76aba362c43daafba8a5c21d" +source = "git+https://github.com/dashpay/platform?rev=a18bd1586858ef680124e150caad6a7dc21d0b64#a18bd1586858ef680124e150caad6a7dc21d0b64" dependencies = [ "arc-swap", "async-trait", @@ -2148,7 +2148,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=18c68d4c10ed349f11358e0a9f7c8c59eab8e098#18c68d4c10ed349f11358e0a9f7c8c59eab8e098" +source = "git+https://github.com/dashpay/rust-dashcore?branch=dash-evo-tool#34f0921ee6881d6c88cbd58caad33a6fe6575f24" dependencies = [ "async-trait", "chrono", @@ -2177,7 +2177,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=18c68d4c10ed349f11358e0a9f7c8c59eab8e098#18c68d4c10ed349f11358e0a9f7c8c59eab8e098" +source = "git+https://github.com/dashpay/rust-dashcore?branch=dash-evo-tool#34f0921ee6881d6c88cbd58caad33a6fe6575f24" dependencies = [ "anyhow", "base64-compat", @@ -2203,12 +2203,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=18c68d4c10ed349f11358e0a9f7c8c59eab8e098#18c68d4c10ed349f11358e0a9f7c8c59eab8e098" +source = "git+https://github.com/dashpay/rust-dashcore?branch=dash-evo-tool#34f0921ee6881d6c88cbd58caad33a6fe6575f24" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=18c68d4c10ed349f11358e0a9f7c8c59eab8e098#18c68d4c10ed349f11358e0a9f7c8c59eab8e098" +source = "git+https://github.com/dashpay/rust-dashcore?branch=dash-evo-tool#34f0921ee6881d6c88cbd58caad33a6fe6575f24" dependencies = [ "dashcore-rpc-json", "hex", @@ -2221,7 +2221,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=18c68d4c10ed349f11358e0a9f7c8c59eab8e098#18c68d4c10ed349f11358e0a9f7c8c59eab8e098" +source = "git+https://github.com/dashpay/rust-dashcore?branch=dash-evo-tool#34f0921ee6881d6c88cbd58caad33a6fe6575f24" dependencies = [ "bincode 2.0.1", "dashcore", @@ -2236,7 +2236,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=18c68d4c10ed349f11358e0a9f7c8c59eab8e098#18c68d4c10ed349f11358e0a9f7c8c59eab8e098" +source = "git+https://github.com/dashpay/rust-dashcore?branch=dash-evo-tool#34f0921ee6881d6c88cbd58caad33a6fe6575f24" dependencies = [ "bincode 2.0.1", "dashcore-private", @@ -2247,7 +2247,7 @@ dependencies = [ [[package]] name = "dashpay-contract" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fplatform-wallet-storage-rehydration#debf67bdae8d72ae76aba362c43daafba8a5c21d" +source = "git+https://github.com/dashpay/platform?rev=a18bd1586858ef680124e150caad6a7dc21d0b64#a18bd1586858ef680124e150caad6a7dc21d0b64" dependencies = [ "platform-value", "platform-version", @@ -2258,7 +2258,7 @@ dependencies = [ [[package]] name = "data-contracts" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fplatform-wallet-storage-rehydration#debf67bdae8d72ae76aba362c43daafba8a5c21d" +source = "git+https://github.com/dashpay/platform?rev=a18bd1586858ef680124e150caad6a7dc21d0b64#a18bd1586858ef680124e150caad6a7dc21d0b64" dependencies = [ "dashpay-contract", "document-history-contract", @@ -2527,7 +2527,7 @@ dependencies = [ [[package]] name = "document-history-contract" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fplatform-wallet-storage-rehydration#debf67bdae8d72ae76aba362c43daafba8a5c21d" +source = "git+https://github.com/dashpay/platform?rev=a18bd1586858ef680124e150caad6a7dc21d0b64#a18bd1586858ef680124e150caad6a7dc21d0b64" dependencies = [ "platform-value", "platform-version", @@ -2556,7 +2556,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "dpns-contract" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fplatform-wallet-storage-rehydration#debf67bdae8d72ae76aba362c43daafba8a5c21d" +source = "git+https://github.com/dashpay/platform?rev=a18bd1586858ef680124e150caad6a7dc21d0b64#a18bd1586858ef680124e150caad6a7dc21d0b64" dependencies = [ "platform-value", "platform-version", @@ -2567,7 +2567,7 @@ dependencies = [ [[package]] name = "dpp" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fplatform-wallet-storage-rehydration#debf67bdae8d72ae76aba362c43daafba8a5c21d" +source = "git+https://github.com/dashpay/platform?rev=a18bd1586858ef680124e150caad6a7dc21d0b64#a18bd1586858ef680124e150caad6a7dc21d0b64" dependencies = [ "anyhow", "async-trait", @@ -2617,7 +2617,7 @@ dependencies = [ [[package]] name = "dpp-json-convertible-derive" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fplatform-wallet-storage-rehydration#debf67bdae8d72ae76aba362c43daafba8a5c21d" +source = "git+https://github.com/dashpay/platform?rev=a18bd1586858ef680124e150caad6a7dc21d0b64#a18bd1586858ef680124e150caad6a7dc21d0b64" dependencies = [ "proc-macro2", "quote", @@ -2627,7 +2627,7 @@ dependencies = [ [[package]] name = "drive" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fplatform-wallet-storage-rehydration#debf67bdae8d72ae76aba362c43daafba8a5c21d" +source = "git+https://github.com/dashpay/platform?rev=a18bd1586858ef680124e150caad6a7dc21d0b64#a18bd1586858ef680124e150caad6a7dc21d0b64" dependencies = [ "bincode 2.0.1", "byteorder", @@ -2652,7 +2652,7 @@ dependencies = [ [[package]] name = "drive-proof-verifier" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fplatform-wallet-storage-rehydration#debf67bdae8d72ae76aba362c43daafba8a5c21d" +source = "git+https://github.com/dashpay/platform?rev=a18bd1586858ef680124e150caad6a7dc21d0b64#a18bd1586858ef680124e150caad6a7dc21d0b64" dependencies = [ "bincode 2.0.1", "dapi-grpc", @@ -3675,7 +3675,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=18c68d4c10ed349f11358e0a9f7c8c59eab8e098#18c68d4c10ed349f11358e0a9f7c8c59eab8e098" +source = "git+https://github.com/dashpay/rust-dashcore?branch=dash-evo-tool#34f0921ee6881d6c88cbd58caad33a6fe6575f24" [[package]] name = "gl_generator" @@ -4999,7 +4999,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=18c68d4c10ed349f11358e0a9f7c8c59eab8e098#18c68d4c10ed349f11358e0a9f7c8c59eab8e098" +source = "git+https://github.com/dashpay/rust-dashcore?branch=dash-evo-tool#34f0921ee6881d6c88cbd58caad33a6fe6575f24" dependencies = [ "async-trait", "base58ck", @@ -5022,7 +5022,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=18c68d4c10ed349f11358e0a9f7c8c59eab8e098#18c68d4c10ed349f11358e0a9f7c8c59eab8e098" +source = "git+https://github.com/dashpay/rust-dashcore?branch=dash-evo-tool#34f0921ee6881d6c88cbd58caad33a6fe6575f24" dependencies = [ "async-trait", "dashcore", @@ -5046,7 +5046,7 @@ dependencies = [ [[package]] name = "keyword-search-contract" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fplatform-wallet-storage-rehydration#debf67bdae8d72ae76aba362c43daafba8a5c21d" +source = "git+https://github.com/dashpay/platform?rev=a18bd1586858ef680124e150caad6a7dc21d0b64#a18bd1586858ef680124e150caad6a7dc21d0b64" dependencies = [ "platform-value", "platform-version", @@ -5259,7 +5259,7 @@ dependencies = [ [[package]] name = "masternode-reward-shares-contract" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fplatform-wallet-storage-rehydration#debf67bdae8d72ae76aba362c43daafba8a5c21d" +source = "git+https://github.com/dashpay/platform?rev=a18bd1586858ef680124e150caad6a7dc21d0b64#a18bd1586858ef680124e150caad6a7dc21d0b64" dependencies = [ "platform-value", "platform-version", @@ -6471,7 +6471,7 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "platform-encryption" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fplatform-wallet-storage-rehydration#debf67bdae8d72ae76aba362c43daafba8a5c21d" +source = "git+https://github.com/dashpay/platform?rev=a18bd1586858ef680124e150caad6a7dc21d0b64#a18bd1586858ef680124e150caad6a7dc21d0b64" dependencies = [ "aes", "cbc", @@ -6484,7 +6484,7 @@ dependencies = [ [[package]] name = "platform-serialization" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fplatform-wallet-storage-rehydration#debf67bdae8d72ae76aba362c43daafba8a5c21d" +source = "git+https://github.com/dashpay/platform?rev=a18bd1586858ef680124e150caad6a7dc21d0b64#a18bd1586858ef680124e150caad6a7dc21d0b64" dependencies = [ "bincode 2.0.1", "platform-version", @@ -6493,7 +6493,7 @@ dependencies = [ [[package]] name = "platform-serialization-derive" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fplatform-wallet-storage-rehydration#debf67bdae8d72ae76aba362c43daafba8a5c21d" +source = "git+https://github.com/dashpay/platform?rev=a18bd1586858ef680124e150caad6a7dc21d0b64#a18bd1586858ef680124e150caad6a7dc21d0b64" dependencies = [ "proc-macro2", "quote", @@ -6504,7 +6504,7 @@ dependencies = [ [[package]] name = "platform-value" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fplatform-wallet-storage-rehydration#debf67bdae8d72ae76aba362c43daafba8a5c21d" +source = "git+https://github.com/dashpay/platform?rev=a18bd1586858ef680124e150caad6a7dc21d0b64#a18bd1586858ef680124e150caad6a7dc21d0b64" dependencies = [ "base64 0.22.1", "bincode 2.0.1", @@ -6524,7 +6524,7 @@ dependencies = [ [[package]] name = "platform-version" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fplatform-wallet-storage-rehydration#debf67bdae8d72ae76aba362c43daafba8a5c21d" +source = "git+https://github.com/dashpay/platform?rev=a18bd1586858ef680124e150caad6a7dc21d0b64#a18bd1586858ef680124e150caad6a7dc21d0b64" dependencies = [ "bincode 2.0.1", "grovedb-version 5.0.1", @@ -6535,7 +6535,7 @@ dependencies = [ [[package]] name = "platform-versioning" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fplatform-wallet-storage-rehydration#debf67bdae8d72ae76aba362c43daafba8a5c21d" +source = "git+https://github.com/dashpay/platform?rev=a18bd1586858ef680124e150caad6a7dc21d0b64#a18bd1586858ef680124e150caad6a7dc21d0b64" dependencies = [ "proc-macro2", "quote", @@ -6545,7 +6545,7 @@ dependencies = [ [[package]] name = "platform-wallet" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fplatform-wallet-storage-rehydration#debf67bdae8d72ae76aba362c43daafba8a5c21d" +source = "git+https://github.com/dashpay/platform?rev=a18bd1586858ef680124e150caad6a7dc21d0b64#a18bd1586858ef680124e150caad6a7dc21d0b64" dependencies = [ "arc-swap", "async-trait", @@ -6577,7 +6577,7 @@ dependencies = [ [[package]] name = "platform-wallet-storage" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fplatform-wallet-storage-rehydration#debf67bdae8d72ae76aba362c43daafba8a5c21d" +source = "git+https://github.com/dashpay/platform?rev=a18bd1586858ef680124e150caad6a7dc21d0b64#a18bd1586858ef680124e150caad6a7dc21d0b64" dependencies = [ "apple-native-keyring-store", "argon2", @@ -6859,7 +6859,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ "heck", - "itertools 0.10.5", + "itertools 0.14.0", "log", "multimap", "petgraph", @@ -6880,7 +6880,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools 0.10.5", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -7574,7 +7574,7 @@ dependencies = [ [[package]] name = "rs-dapi-client" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fplatform-wallet-storage-rehydration#debf67bdae8d72ae76aba362c43daafba8a5c21d" +source = "git+https://github.com/dashpay/platform?rev=a18bd1586858ef680124e150caad6a7dc21d0b64#a18bd1586858ef680124e150caad6a7dc21d0b64" dependencies = [ "backon", "chrono", @@ -7600,7 +7600,7 @@ dependencies = [ [[package]] name = "rs-sdk-trusted-context-provider" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fplatform-wallet-storage-rehydration#debf67bdae8d72ae76aba362c43daafba8a5c21d" +source = "git+https://github.com/dashpay/platform?rev=a18bd1586858ef680124e150caad6a7dc21d0b64#a18bd1586858ef680124e150caad6a7dc21d0b64" dependencies = [ "arc-swap", "dash-async", @@ -8842,7 +8842,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "token-history-contract" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fplatform-wallet-storage-rehydration#debf67bdae8d72ae76aba362c43daafba8a5c21d" +source = "git+https://github.com/dashpay/platform?rev=a18bd1586858ef680124e150caad6a7dc21d0b64#a18bd1586858ef680124e150caad6a7dc21d0b64" dependencies = [ "platform-value", "platform-version", @@ -9687,7 +9687,7 @@ dependencies = [ [[package]] name = "wallet-utils-contract" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fplatform-wallet-storage-rehydration#debf67bdae8d72ae76aba362c43daafba8a5c21d" +source = "git+https://github.com/dashpay/platform?rev=a18bd1586858ef680124e150caad6a7dc21d0b64#a18bd1586858ef680124e150caad6a7dc21d0b64" dependencies = [ "platform-value", "platform-version", @@ -10268,7 +10268,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -10943,7 +10943,7 @@ dependencies = [ [[package]] name = "withdrawals-contract" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?branch=feat%2Fplatform-wallet-storage-rehydration#debf67bdae8d72ae76aba362c43daafba8a5c21d" +source = "git+https://github.com/dashpay/platform?rev=a18bd1586858ef680124e150caad6a7dc21d0b64#a18bd1586858ef680124e150caad6a7dc21d0b64" dependencies = [ "num_enum 0.5.11", "platform-value", diff --git a/Cargo.toml b/Cargo.toml index 420b53f76..694b5fedf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ eframe = { version = "0.35.0", features = ["persistence", "wgpu"] } base64 = "0.22.1" # TODO: GHSA-7gcf-g7xr-8hxj (serde_with <3.21.0) is unfixable from here — the 2.x pin lives in # dashcore-rpc-json (dashpay/rust-dashcore, rpc-json/Cargo.toml). Re-check when these pins move. -dash-sdk = { git = "https://github.com/dashpay/platform", branch = "feat/platform-wallet-storage-rehydration", features = [ +dash-sdk = { git = "https://github.com/dashpay/platform", rev = "a18bd1586858ef680124e150caad6a7dc21d0b64", features = [ "core_key_wallet", "core_key_wallet_manager", "core_bincode", @@ -30,12 +30,12 @@ dash-sdk = { git = "https://github.com/dashpay/platform", branch = "feat/platfor "core_spv", "shielded", ] } -rs-sdk-trusted-context-provider = { git = "https://github.com/dashpay/platform", branch = "feat/platform-wallet-storage-rehydration" } -platform-wallet = { git = "https://github.com/dashpay/platform", branch = "feat/platform-wallet-storage-rehydration", features = [ +rs-sdk-trusted-context-provider = { git = "https://github.com/dashpay/platform", rev = "a18bd1586858ef680124e150caad6a7dc21d0b64" } +platform-wallet = { git = "https://github.com/dashpay/platform", rev = "a18bd1586858ef680124e150caad6a7dc21d0b64", features = [ "serde", "shielded", ] } -platform-wallet-storage = { git = "https://github.com/dashpay/platform", branch = "feat/platform-wallet-storage-rehydration", features = [ +platform-wallet-storage = { git = "https://github.com/dashpay/platform", rev = "a18bd1586858ef680124e150caad6a7dc21d0b64", features = [ "shielded", ] } zip32 = "0.2.0" diff --git a/docs/user-stories.md b/docs/user-stories.md index 4ceedd58f..379cc114e 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -457,6 +457,16 @@ As a developer, I want to transfer credits privately from my shielded pool to an - Spending is paused until the shielded balance is verified, and the button is disabled with a clear reason while verification is in progress. - Available only on Platform protocol v12 or later when Expert view or Developer view is selected. +### SND-017: "Max" is verified against what the wallet can actually build [Implemented] +**Persona:** Alex, Priya, Jordan + +As a user, I want "Max" (and the amount check behind it) to reflect what my Core wallet can genuinely send when shielding DASH, funding a Platform address through the Simple builder-driven form, sending directly to an identity, or funding an identity from my wallet balance, so that the amount I'm offered — or type in myself — is not rejected for exceeding what the wallet can build. + +- "Max" and amount validation both ask the wallet directly for its actual sendable ceiling, reserving the relevant operation's fee, instead of estimating from the on-screen balance — both derive from the same builder quote, including for an amount typed in by hand rather than produced by the Max button. When the wallet's spendable inputs change after a quote, Max steps back to "Checking the available amount…" and validation waits for a fresh quote rather than accepting a stale ceiling. +- While the check is running, the amount field shows "Checking the available amount…"; if it fails, "The available amount could not be checked." appears with a "Retry available amount check" button. +- Funding from a received deposit is capped by what actually arrived at that specific deposit address, never by unrelated funds elsewhere in the wallet — see SND-014 (Core-to-Core Max), which uses a separate, simpler network-fee-only calculation not covered by this story. +- The Advanced manual-input Platform-address flow validates against the Core inputs selected by the user and is not covered by this builder-ceiling story. + --- ## Asset Locks (ALK) diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index ed2e8063e..3881e8666 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -234,6 +234,17 @@ pub enum TaskError { >, }, + /// The non-broadcasting asset-lock builder probe could not determine a safe Max. + #[error( + "The wallet's available amount could not be checked. Wait a moment and try again." + )] + AssetLockBalanceQueryFailed { + #[source] + source: Box< + dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::transaction_builder::BuilderError, + >, + }, + /// The payment would need more individual unspent outputs than fit in a /// single standard transaction. #[error( diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index d0826cf2a..e75273dd1 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -331,6 +331,12 @@ pub enum BackendTaskContext { ScheduledVoteSweep { network: Network }, /// Receive-address derivation for one wallet's deposit flow. GenerateReceiveAddress { seed_hash: WalletSeedHash }, + /// Live asset-lock builder ceiling query for one wallet. + AssetLockMaxAmount { + seed_hash: WalletSeedHash, + snapshot_generation: u64, + request_id: u64, + }, /// One HD-wallet or imported-key alias update. WalletRename(WalletTask), /// The detection pass for one identity's legacy-recovery offer. @@ -391,6 +397,17 @@ impl BackendTaskContext { } } + pub(crate) fn asset_lock_max_amount_request(&self) -> Option<(WalletSeedHash, u64, u64)> { + match self.operation() { + Self::AssetLockMaxAmount { + seed_hash, + snapshot_generation, + request_id, + } => Some((*seed_hash, *snapshot_generation, *request_id)), + _ => None, + } + } + pub(crate) fn wallet_rename_task(&self) -> Option<&WalletTask> { match self.operation() { Self::WalletRename(task) => Some(task), @@ -458,6 +475,15 @@ impl From<&BackendTask> for BackendTaskContext { seed_hash: *seed_hash, } } + BackendTask::WalletTask(WalletTask::GetAssetLockMaxAmount { + seed_hash, + snapshot_generation, + request_id, + }) => Self::AssetLockMaxAmount { + seed_hash: *seed_hash, + snapshot_generation: *snapshot_generation, + request_id: *request_id, + }, BackendTask::WalletTask( task @ (WalletTask::RenameHdWallet { .. } | WalletTask::RenameSingleKeyWallet { .. }), @@ -625,6 +651,15 @@ pub enum BackendTaskSuccessResult { seed_hash: WalletSeedHash, locks: Vec, }, + /// Largest asset-lock credit output the live upstream builder accepts. + AssetLockMaxAmount { + seed_hash: WalletSeedHash, + snapshot_generation: u64, + request_id: u64, + amount_duffs: u64, + observed_inputs: crate::wallet_backend::AssetLockInputState, + is_partial: bool, + }, /// Platform address balances fetched from Platform PlatformAddressBalances { seed_hash: WalletSeedHash, @@ -1291,6 +1326,21 @@ impl AppContext { .list_tracked_asset_locks(&seed_hash) .await .map(|locks| BackendTaskSuccessResult::TrackedAssetLocks { seed_hash, locks }), + WalletTask::GetAssetLockMaxAmount { + seed_hash, + snapshot_generation, + request_id, + } => backend + .asset_lock_max_amount(&seed_hash) + .await + .map(|quote| BackendTaskSuccessResult::AssetLockMaxAmount { + seed_hash, + snapshot_generation, + request_id, + amount_duffs: quote.amount_duffs, + observed_inputs: quote.observed_inputs, + is_partial: quote.is_partial, + }), WalletTask::FetchPlatformAddressBalances { seed_hash } => { self.fetch_platform_address_balances(seed_hash).await } @@ -1716,6 +1766,21 @@ mod tests { ); } + #[test] + fn backend_task_context_preserves_asset_lock_request_identity() { + let seed_hash = [0x39; 32]; + let task = BackendTask::WalletTask(WalletTask::GetAssetLockMaxAmount { + seed_hash, + snapshot_generation: 7, + request_id: 42, + }); + + assert_eq!( + BackendTaskContext::from(&task).asset_lock_max_amount_request(), + Some((seed_hash, 7, 42)) + ); + } + #[test] fn backend_task_context_identifies_network_database_clear() { let task = BackendTask::SystemTask(SystemTask::ClearNetworkDatabase); diff --git a/src/backend_task/wallet/mod.rs b/src/backend_task/wallet/mod.rs index c918d6e9c..84cfc11ee 100644 --- a/src/backend_task/wallet/mod.rs +++ b/src/backend_task/wallet/mod.rs @@ -294,6 +294,13 @@ pub enum WalletTask { ListTrackedAssetLocks { seed_hash: WalletSeedHash, }, + /// Dry-run the live upstream asset-lock builder to obtain the largest + /// credit output its current UTXO set can fund. + GetAssetLockMaxAmount { + seed_hash: WalletSeedHash, + snapshot_generation: u64, + request_id: u64, + }, /// Fund Platform addresses from a tracked asset lock identified by its /// credit-output outpoint. The proof and credit-output key are recovered /// from the upstream `AssetLockManager` and the wallet's funding diff --git a/src/context/mod.rs b/src/context/mod.rs index fee5c8ce8..8828108fd 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -1505,6 +1505,23 @@ impl AppContext { .unwrap_or_default() } + /// Monotonic generation of the event-pushed snapshot for one wallet. + pub fn snapshot_generation(&self, seed_hash: &WalletSeedHash) -> u64 { + self.wallet_backend() + .map(|wb| wb.wallet_snapshot_generation(seed_hash)) + .unwrap_or_default() + } + + /// Snapshot generation, exact builder-input composition, and its revision. + pub fn asset_lock_probe_snapshot( + &self, + seed_hash: &WalletSeedHash, + ) -> (u64, crate::wallet_backend::AssetLockInputState, u64) { + self.wallet_backend() + .map(|wallet_backend| wallet_backend.asset_lock_probe_snapshot(seed_hash)) + .unwrap_or_default() + } + /// Number of UTXOs in the wallet's display snapshot. Used to estimate the /// Core (L1) transaction fee for a "Max" send, which spends every UTXO. /// diff --git a/src/context/test_support.rs b/src/context/test_support.rs index fb6cd0bf3..5110f2ab0 100644 --- a/src/context/test_support.rs +++ b/src/context/test_support.rs @@ -16,12 +16,26 @@ use crate::wallet_backend::DetKv; /// to exercise the settings read-modify-write and feature-gate paths. pub(crate) fn test_app_context(dir: &Path) -> Arc { let app_kv = AppContext::open_app_kv(dir).expect("open app k/v"); - test_app_context_with_kv(dir, app_kv) + test_app_context_with_kv_and_network(dir, app_kv, Network::Testnet) +} + +/// Build a throwaway [`AppContext`] for a specific network. +pub(crate) fn test_app_context_for_network(dir: &Path, network: Network) -> Arc { + let app_kv = AppContext::open_app_kv(dir).expect("open app k/v"); + test_app_context_with_kv_and_network(dir, app_kv, network) } /// [`test_app_context`] with a caller-supplied app k/v store, so a test can /// inject a fault-injecting backing store (see `kv_test_support::FailingKv`). pub(crate) fn test_app_context_with_kv(dir: &Path, app_kv: Arc) -> Arc { + test_app_context_with_kv_and_network(dir, app_kv, Network::Testnet) +} + +fn test_app_context_with_kv_and_network( + dir: &Path, + app_kv: Arc, + network: Network, +) -> Arc { crate::app_dir::ensure_env_file(dir); let db = Arc::new(crate::database::Database::new(dir.join("data.db")).expect("db")); db.create_tables(true).expect("create tables"); @@ -29,7 +43,7 @@ pub(crate) fn test_app_context_with_kv(dir: &Path, app_kv: Arc) -> Arc u64 { + builder_max.saturating_sub(reserve) +} + +/// Validate a user-entered amount against the live builder-derived ceiling. +pub fn validate_asset_lock_amount( + amount_duffs: u64, + reserve_duffs: u64, + builder_max_duffs: u64, +) -> Result<(), AssetLockAmountError> { + let required_duffs = amount_duffs + .checked_add(reserve_duffs) + .ok_or(AssetLockAmountError::Overflow)?; + if required_duffs > builder_max_duffs { + return Err(AssetLockAmountError::ExceedsMaximum { + maximum_amount_duffs: asset_lock_user_max_amount(builder_max_duffs, reserve_duffs), + }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{AssetLockAmountError, asset_lock_user_max_amount, validate_asset_lock_amount}; + + #[test] + fn builder_ceiling_validation_reserves_operation_fee() { + assert_eq!(asset_lock_user_max_amount(10_000, 1_000), 9_000); + assert_eq!(validate_asset_lock_amount(9_000, 1_000, 10_000), Ok(())); + assert_eq!( + validate_asset_lock_amount(9_001, 1_000, 10_000), + Err(AssetLockAmountError::ExceedsMaximum { + maximum_amount_duffs: 9_000, + }) + ); + } +} diff --git a/src/model/mod.rs b/src/model/mod.rs index 0b26dcef1..0862bb2b2 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -1,5 +1,6 @@ pub mod address; pub mod amount; +pub mod asset_lock; pub mod contested_name; pub mod dashpay; pub mod dashpay_derivation; diff --git a/src/ui/identities/add_new_identity_screen/by_receive_deposit.rs b/src/ui/identities/add_new_identity_screen/by_receive_deposit.rs index d8fcba71f..70e168ccf 100644 --- a/src/ui/identities/add_new_identity_screen/by_receive_deposit.rs +++ b/src/ui/identities/add_new_identity_screen/by_receive_deposit.rs @@ -145,6 +145,10 @@ impl AddNewIdentityScreen { let mut action = AppAction::None; self.reconcile_funding_deposit(); let step = *self.step.read_recover(); + let seed_hash = self + .selected_wallet + .as_ref() + .and_then(|wallet| wallet.read().ok().map(|wallet| wallet.seed_hash())); if step == WalletFundedScreenStep::WaitingOnFunds { ui.heading(format!( @@ -171,6 +175,38 @@ impl AddNewIdentityScreen { // committed to the pending transaction, so the input's max recomputes to // 0 and would show a stale "exceeds maximum" error over a succeeding op. if step == WalletFundedScreenStep::FundsReceived { + let Some(seed_hash) = seed_hash else { + if ui.button("Choose a different funding method").clicked() { + self.reset_to_choose_funding(); + } + return action; + }; + let failed = self.asset_lock_balance.is_failed(&seed_hash); + let loading = self.asset_lock_quote_is_loading(&seed_hash); + if failed || loading { + ui.label(if failed { + "The available amount could not be checked." + } else { + "Checking the available amount…" + }); + if self.asset_lock_balance.should_offer_retry(&seed_hash) + && ui.button("Retry available amount check").clicked() + { + self.asset_lock_balance.invalidate_one(&seed_hash); + } + if ui.button("Choose a different funding method").clicked() { + self.reset_to_choose_funding(); + } + return action; + } + if self.asset_lock_balance.should_offer_retry(&seed_hash) { + ui.label( + "The amount shown is safe but may be lower than your full available amount.", + ); + if ui.button("Retry available amount check").clicked() { + self.asset_lock_balance.invalidate_one(&seed_hash); + } + } self.render_funding_amount_input(ui); let has_valid_amount = self diff --git a/src/ui/identities/add_new_identity_screen/by_using_unused_balance.rs b/src/ui/identities/add_new_identity_screen/by_using_unused_balance.rs index 5f967a3c6..d611b6f73 100644 --- a/src/ui/identities/add_new_identity_screen/by_using_unused_balance.rs +++ b/src/ui/identities/add_new_identity_screen/by_using_unused_balance.rs @@ -19,10 +19,11 @@ impl AddNewIdentityScreen { } }; - let spendable_balance: u64 = self - .app_context - .snapshot_balance(&wallet.seed_hash()) - .spendable(); + let seed_hash = wallet.seed_hash(); + let spendable_balance = self + .asset_lock_balance + .get(&seed_hash) + .unwrap_or_else(|| self.app_context.snapshot_balance(&seed_hash).spendable()); let dash_balance = spendable_balance as f64 * 1e-8; // Convert to DASH units @@ -45,10 +46,7 @@ impl AddNewIdentityScreen { fn render_insufficient_wallet_balance_banner(&self, ui: &mut egui::Ui) -> Option { let selected_wallet = self.selected_wallet.as_ref()?; let spendable_duffs = match selected_wallet.read() { - Ok(w) => self - .app_context - .snapshot_balance(&w.seed_hash()) - .spendable(), + Ok(w) => self.asset_lock_balance.get(&w.seed_hash())?, Err(_) => { ui.label("Wallet is busy. Try again in a moment."); return Some(AppAction::None); @@ -102,8 +100,38 @@ impl AddNewIdentityScreen { self.show_wallet_balance(ui); ui.add_space(5.0); + let seed_hash = self + .selected_wallet + .as_ref() + .and_then(|wallet| wallet.read().ok().map(|wallet| wallet.seed_hash())); + let Some(seed_hash) = seed_hash else { + return action; + }; + let failed = self.asset_lock_balance.is_failed(&seed_hash); + let loading = self.asset_lock_quote_is_loading(&seed_hash); + if failed || loading { + ui.label(if failed { + "The available amount could not be checked." + } else { + "Checking the available amount…" + }); + if self.asset_lock_balance.should_offer_retry(&seed_hash) + && ui.button("Retry available amount check").clicked() + { + self.asset_lock_balance.invalidate_one(&seed_hash); + } + return action; + } + if self.asset_lock_balance.should_offer_retry(&seed_hash) { + ui.label("The amount shown is safe but may be lower than your full available amount."); + if ui.button("Retry available amount check").clicked() { + self.asset_lock_balance.invalidate_one(&seed_hash); + } + } + if let Some(insufficient_action) = self.render_insufficient_wallet_balance_banner(ui) { - return insufficient_action; + action |= insufficient_action; + return action; } self.render_funding_amount_input(ui); diff --git a/src/ui/identities/add_new_identity_screen/mod.rs b/src/ui/identities/add_new_identity_screen/mod.rs index 2d6948ee3..893c4f1c8 100644 --- a/src/ui/identities/add_new_identity_screen/mod.rs +++ b/src/ui/identities/add_new_identity_screen/mod.rs @@ -4,7 +4,7 @@ mod by_using_unused_asset_lock; mod by_using_unused_balance; mod success_screen; -use crate::app::{AppAction, BackendTasksExecutionMode}; +use crate::app::AppAction; use crate::backend_task::core::CoreItem; use crate::backend_task::error::TaskError; use crate::backend_task::identity::{ @@ -14,7 +14,8 @@ use crate::backend_task::identity::{ use crate::backend_task::wallet::WalletTask; use crate::backend_task::{BackendTask, BackendTaskContext, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; -use crate::model::fee_estimation::format_credits_as_dash; +use crate::model::asset_lock::{AssetLockAmountError, validate_asset_lock_amount}; +use crate::model::fee_estimation::{format_credits_as_dash, format_duffs_as_dash}; use crate::model::secret::Secret; use crate::model::wallet::{Wallet, WalletSeedHash}; use crate::ui::components::MessageBanner; @@ -27,12 +28,14 @@ use crate::ui::components::wallet_unlock_popup::{ }; use crate::ui::identities::funding_common::{ FundingMethod, WalletFundedScreenStep, default_funding_state, deposit_event_outcome, - funding_method_after_switch, max_amount_after_fee_reserve, spendable_covers_minimum, - step_after_task_failure, wallet_selection_combo, + funding_method_after_switch, max_amount_after_fee_reserve, receive_deposit_ceiling_duffs, + spendable_covers_minimum, step_after_task_failure, wallet_selection_combo, }; -use crate::ui::state::TrackedAssetLockCache; +use crate::ui::state::{AssetLockBalanceCache, TrackedAssetLockCache}; use crate::ui::theme::DashColors; -use crate::ui::{MessageType, ScreenLike}; +use crate::ui::{ + MessageType, ScreenLike, append_concurrent_backend_tasks, can_append_concurrent_backend_tasks, +}; use crate::wallet_backend::poison::RwLockRecover; use dash_sdk::dashcore_rpc::dashcore::Address; use dash_sdk::dashcore_rpc::dashcore::transaction::special_transaction::TransactionPayload; @@ -143,6 +146,7 @@ pub struct AddNewIdentityScreen { /// via the App Task System. Backs both the funding-method gate and the /// asset-lock picker. asset_lock_cache: TrackedAssetLockCache, + asset_lock_balance: AssetLockBalanceCache, } impl AddNewIdentityScreen { @@ -211,6 +215,7 @@ impl AddNewIdentityScreen { show_advanced_options: false, completed_fee_result: None, asset_lock_cache: TrackedAssetLockCache::default(), + asset_lock_balance: AssetLockBalanceCache::default(), }; if let Some(wallet) = selected_wallet { @@ -460,21 +465,54 @@ impl AddNewIdentityScreen { rendered } - /// Whether `wallet` can cover the estimated identity-creation fee out of its - /// spendable balance — the same sufficiency check as the "not enough Dash" - /// banner in `by_using_unused_balance.rs`, so a dust balance (positive but - /// below the fee) is never treated as fundable. Poison-tolerant: a busy - /// wallet lock reads as "cannot afford" rather than panicking. - fn wallet_can_afford_creation(app_context: &AppContext, wallet: &Arc>) -> bool { + /// Whether the loaded builder ceiling covers the same minimum as the + /// "not enough Dash" banner. An unloaded quote does not block the option. + fn wallet_can_afford_creation(&self, wallet: &Arc>) -> bool { let Ok(w) = wallet.read() else { return false; }; - let spendable_duffs = app_context.snapshot_balance(&w.seed_hash()).spendable(); - let key_count = default_identity_key_specs(app_context.dashpay_contract.id()).len() + 1; - let minimum_credits = app_context + let key_count = self.identity_keys.others.len() + 1; + let minimum_credits = self + .app_context .fee_estimator() .estimate_identity_create(key_count); - spendable_covers_minimum(spendable_duffs, minimum_credits) + self.asset_lock_balance + .get(&w.seed_hash()) + .is_none_or(|ceiling| spendable_covers_minimum(ceiling, minimum_credits)) + } + + /// Whether the builder ceiling for the wallet's current spendable inputs + /// is still being checked (no quote yet, or the quote predates an input + /// change and is being revalidated). + fn asset_lock_quote_is_loading(&self, seed_hash: &WalletSeedHash) -> bool { + let (_, input_state, _) = self.app_context.asset_lock_probe_snapshot(seed_hash); + self.asset_lock_balance + .get_current(seed_hash, &input_state) + .is_none() + } + + /// Builder ceiling for Max and dispatch validation — one accessor for + /// both, valid only while the quote matches current wallet inputs, so Max + /// can never offer an amount validation would refuse. + fn current_validation_ceiling_duffs(&self, funding_method: FundingMethod) -> Option { + let seed_hash = self + .selected_wallet + .as_ref() + .and_then(|wallet| wallet.read().ok()) + .map(|wallet| wallet.seed_hash())?; + let (_, input_state, _) = self.app_context.asset_lock_probe_snapshot(&seed_hash); + let wallet_ceiling_duffs = self + .asset_lock_balance + .get_current(&seed_hash, &input_state)?; + + match funding_method { + FundingMethod::UseWalletBalance => Some(wallet_ceiling_duffs), + FundingMethod::ReceiveDeposit => Some(receive_deposit_ceiling_duffs( + wallet_ceiling_duffs, + self.funding_address_balance_duffs, + )), + _ => None, + } } /// Update selected wallet and trigger all dependent actions, like updating @@ -488,13 +526,14 @@ impl AddNewIdentityScreen { let is_open = wallet.read().is_ok_and(|w| w.is_open()); self.selected_wallet = Some(wallet); + self.asset_lock_balance.invalidate(); self.wallet_open_attempted = false; self.identity_id_number = self.next_identity_id(); let can_afford = self .selected_wallet .as_ref() - .is_some_and(|wallet| Self::wallet_can_afford_creation(&self.app_context, wallet)); + .is_some_and(|wallet| self.wallet_can_afford_creation(wallet)); let current = ( self.funding_method .read() @@ -579,7 +618,16 @@ impl AddNewIdentityScreen { ( self.asset_lock_cache.has_unused(&seed_hash) || self.asset_lock_cache.is_failed(&seed_hash), - self.app_context.snapshot_has_balance(&seed_hash), + self.asset_lock_balance + .get(&seed_hash) + .is_none_or(|ceiling| { + let key_count = self.identity_keys.others.len() + 1; + let minimum = self + .app_context + .fee_estimator() + .estimate_identity_create(key_count); + spendable_covers_minimum(ceiling, minimum) + }), ) }; @@ -692,6 +740,30 @@ impl AddNewIdentityScreen { self.funding_amount_input = None; } + /// Reset wallet- and network-bound state after changing contexts. + pub(crate) fn reset_for_network_switch(&mut self) { + self.selected_wallet = None; + self.identity_id_number = 0; + self.funding_asset_lock = None; + self.reset_to_choose_funding(); + self.identity_keys = IdentityKeySpecs::empty(); + self.warming_identity_keys = false; + self.pending_warm_request = None; + self.revealed_wifs.clear(); + self.pending_wif_request = None; + self.wallet_unlock_popup = WalletUnlockPopup::new(); + self.wallet_open_attempted = false; + self.copied_to_clipboard = None; + self.show_pop_up_info = None; + self.successful_qualified_identity_id = None; + self.selected_platform_address_for_funding = None; + self.platform_funding_amount = None; + self.platform_funding_amount_input = None; + self.completed_fee_result = None; + self.asset_lock_cache.invalidate(); + self.asset_lock_balance.invalidate(); + } + // Function to render the key selection mode (Default or Advanced) fn render_key_selection(&mut self, ui: &mut egui::Ui) { // Provide the selection toggle for Default or Advanced mode @@ -1054,24 +1126,40 @@ impl AddNewIdentityScreen { if amount == 0 { return AppAction::None; } - if funding_method == FundingMethod::ReceiveDeposit { - let key_count = self.identity_keys.others.len() + 1; - let fee_credits = self - .app_context - .fee_estimator() - .estimate_identity_create(key_count); - let available_credits = max_amount_after_fee_reserve( - self.funding_address_balance_duffs, - fee_credits, + let Some(max_amount) = self.current_validation_ceiling_duffs(funding_method) else { + let seed_hash = selected_wallet.read_recover().seed_hash(); + MessageBanner::set_global( + self.app_context.egui_ctx(), + self.asset_lock_balance + .validation_unavailable_message(&seed_hash), + MessageType::Warning, ); - if amount.saturating_mul(CREDITS_PER_DUFF) > available_credits { - MessageBanner::set_global( - self.app_context.egui_ctx(), - "That deposit cannot cover this amount. Wait for more funds or choose a smaller amount.", - MessageType::Warning, - ); - return AppAction::None; - } + return AppAction::None; + }; + let key_count = self.identity_keys.others.len() + 1; + let identity_fee_duffs = self + .app_context + .fee_estimator() + .estimate_identity_create(key_count) + .div_ceil(CREDITS_PER_DUFF); + if let Err(error) = + validate_asset_lock_amount(amount, identity_fee_duffs, max_amount) + { + let maximum_amount_duffs = match error { + AssetLockAmountError::Overflow => max_amount, + AssetLockAmountError::ExceedsMaximum { + maximum_amount_duffs, + } => maximum_amount_duffs, + }; + MessageBanner::set_global( + self.app_context.egui_ctx(), + format!( + "You can transfer up to {} right now. Choose a smaller amount or wait for more funds.", + format_duffs_as_dash(maximum_amount_duffs) + ), + MessageType::Warning, + ); + return AppAction::None; } let wallet_seed_hash = hex::encode(selected_wallet.read_recover().seed_hash()); @@ -1146,46 +1234,29 @@ impl AddNewIdentityScreen { fn render_funding_amount_input(&mut self, ui: &mut egui::Ui) { let funding_method = *self.funding_method.read_recover(); - - // Apply the max-amount restriction for both wallet-balance funding and a - // received deposit (which also spends from the wallet balance); reserve - // the estimated identity-creation fee out of the spendable balance so - // "Max" never offers more than the coin selector can actually use. - let (max_amount_credits, show_max_button, fee_hint) = if matches!( - funding_method, - FundingMethod::UseWalletBalance | FundingMethod::ReceiveDeposit - ) { - let spendable_duffs = if funding_method == FundingMethod::ReceiveDeposit { - self.funding_address_balance_duffs + let available_ceiling_duffs = self.current_validation_ceiling_duffs(funding_method); + + // Reserve the estimated identity-creation fee from the relevant ceiling. + let (max_amount_credits, show_max_button, fee_hint) = + if let Some(available_ceiling_duffs) = available_ceiling_duffs { + let key_count = self.identity_keys.others.len() + 1; // +1 for master key + let estimated_fee = self + .app_context + .fee_estimator() + .estimate_identity_create(key_count); + let max_with_fee_reserved = + max_amount_after_fee_reserve(available_ceiling_duffs, estimated_fee); + ( + Some(max_with_fee_reserved), + true, + Some(format!( + "The estimated fee reserves about {}.", + format_credits_as_dash(estimated_fee), + )), + ) } else { - self.selected_wallet - .as_ref() - .and_then(|wallet| wallet.read().ok()) - .map(|wallet| { - self.app_context - .snapshot_balance(&wallet.seed_hash()) - .spendable() - }) - .unwrap_or(0) + (None, false, None) }; - let key_count = self.identity_keys.others.len() + 1; // +1 for master key - let estimated_fee = self - .app_context - .fee_estimator() - .estimate_identity_create(key_count); - let max_with_fee_reserved = - max_amount_after_fee_reserve(spendable_duffs, estimated_fee); - ( - Some(max_with_fee_reserved), - true, - Some(format!( - "The estimated fee reserves about {}.", - format_credits_as_dash(estimated_fee), - )), - ) - } else { - (None, false, None) - }; let should_prefill = self.prefill_funding_amount; let amount_input = self.funding_amount_input.get_or_insert_with(|| { @@ -1326,6 +1397,14 @@ impl AddNewIdentityScreen { } impl ScreenLike for AddNewIdentityScreen { + fn refresh_on_arrival(&mut self) { + self.asset_lock_balance.invalidate(); + } + + fn refresh(&mut self) { + self.asset_lock_balance.invalidate(); + } + fn display_message(&mut self, _message: &str, message_type: MessageType) { if matches!(message_type, MessageType::Error | MessageType::Warning) { let mut step = self.step.write_recover(); @@ -1334,6 +1413,15 @@ impl ScreenLike for AddNewIdentityScreen { } fn display_backend_task_error(&mut self, context: &BackendTaskContext, _error: &TaskError) { + if let Some((seed_hash, snapshot_generation, request_id)) = + context.asset_lock_max_amount_request() + { + self.asset_lock_balance.mark_loading_failed( + &seed_hash, + snapshot_generation, + request_id, + ); + } let selected_seed_hash = self .selected_wallet .as_ref() @@ -1345,6 +1433,15 @@ impl ScreenLike for AddNewIdentityScreen { self.funding_address_request_failed = true; } } + + fn should_suppress_backend_task_error( + &self, + context: &BackendTaskContext, + _error: &TaskError, + ) -> bool { + context.asset_lock_max_amount_request().is_some() + } + fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { match &backend_task_success_result { BackendTaskSuccessResult::IdentityAuthPubkeysWarmed { .. } => { @@ -1368,6 +1465,24 @@ impl ScreenLike for AddNewIdentityScreen { self.asset_lock_cache.store(*seed_hash, locks.clone()); return; } + BackendTaskSuccessResult::AssetLockMaxAmount { + seed_hash, + snapshot_generation, + request_id, + amount_duffs, + observed_inputs, + is_partial, + } => { + self.asset_lock_balance.store( + *seed_hash, + *snapshot_generation, + *request_id, + *amount_duffs, + observed_inputs.clone(), + *is_partial, + ); + return; + } BackendTaskSuccessResult::GeneratedReceiveAddress { seed_hash, address } => { // Adopt the SPV-watched deposit address only for the selected // wallet, so a stale result for another wallet is ignored. @@ -1495,6 +1610,7 @@ impl ScreenLike for AddNewIdentityScreen { crate::ui::RootScreenType::RootScreenIdentities, ); + let mut request_asset_lock_balance = false; action |= island_central_panel(ui, |ui| { let mut inner_action = AppAction::None; @@ -1667,12 +1783,14 @@ impl ScreenLike for AddNewIdentityScreen { inner_action |= self.render_ui_by_using_unused_asset_lock(ui, step_number); }, FundingMethod::UseWalletBalance => { + request_asset_lock_balance = true; inner_action |= self.render_ui_by_using_unused_balance(ui, step_number); }, FundingMethod::UsePlatformAddress => { inner_action |= self.render_ui_by_platform_address(ui, step_number); }, FundingMethod::ReceiveDeposit => { + request_asset_lock_balance = true; inner_action |= self.render_ui_by_receive_deposit(ui, step_number); }, } @@ -1709,66 +1827,77 @@ impl ScreenLike for AddNewIdentityScreen { } } - // Drain the queued end-of-frame backend reads into one concurrent batch - // so none clobbers another (`AppAction`'s `|=` keeps only the last - // value). - let mut pending_tasks: Vec = Vec::new(); - - // Auth-pubkey cache warm (cold-cache cover for the chooser, RK-2). One - // in-flight at a time via `warming_identity_keys`. - if let Some((seed_hash, identity_index)) = self.pending_warm_request.take() { - // Warm at least the default range, plus a margin for any - // advanced-mode keys already added beyond it. - let key_count = self - .default_key_count() - .max(self.identity_keys.others.len() as u32 + 2); - pending_tasks.push(BackendTask::WalletTask( - WalletTask::WarmIdentityAuthPubkeys { - seed_hash, - identity_index, - key_count, - }, - )); - } + if can_append_concurrent_backend_tasks(&action) { + // Drain the queued end-of-frame backend reads into one concurrent + // batch so none clobbers another. + let mut pending_tasks: Vec = Vec::new(); + + // Auth-pubkey cache warm (cold-cache cover for the chooser, RK-2). One + // in-flight at a time via `warming_identity_keys`. + if let Some((seed_hash, identity_index)) = self.pending_warm_request.take() { + // Warm at least the default range, plus a margin for any + // advanced-mode keys already added beyond it. + let key_count = self + .default_key_count() + .max(self.identity_keys.others.len() as u32 + 2); + pending_tasks.push(BackendTask::WalletTask( + WalletTask::WarmIdentityAuthPubkeys { + seed_hash, + identity_index, + key_count, + }, + )); + } - // "Show WIF" derivation (advanced mode); the seed is fetched - // just-in-time in the backend and only the WIF returns. - if let Some((_key_id, derivation_path)) = self.pending_wif_request.take() - && let Some(wallet) = &self.selected_wallet - { - let seed_hash = wallet.read_recover().seed_hash(); - pending_tasks.push(BackendTask::WalletTask(WalletTask::DeriveKeyForDisplay { - seed_hash, - derivation_path, - })); - } + // "Show WIF" derivation (advanced mode); the seed is fetched + // just-in-time in the backend and only the WIF returns. + if let Some((_key_id, derivation_path)) = self.pending_wif_request.take() + && let Some(wallet) = &self.selected_wallet + { + let seed_hash = wallet.read_recover().seed_hash(); + pending_tasks.push(BackendTask::WalletTask(WalletTask::DeriveKeyForDisplay { + seed_hash, + derivation_path, + })); + } - // Fetch the selected wallet's tracked asset locks once (off the UI - // thread) so the funding-method gate and the picker can read them. - if let Some(wallet) = &self.selected_wallet { - let seed_hash = wallet.read_recover().seed_hash(); - if let Some(task) = self.asset_lock_cache.ensure_requested(seed_hash) { - pending_tasks.push(task); + // Fetch the selected wallet's tracked asset locks once (off the UI + // thread) so the funding-method gate and the picker can read them. + if let Some(wallet) = &self.selected_wallet { + let seed_hash = wallet.read_recover().seed_hash(); + if let Some(task) = self.asset_lock_cache.ensure_requested(seed_hash) { + pending_tasks.push(task); + } } - } - // Derive the "Receive a new deposit" address off the UI thread; the QR - // view queues this when it has no address yet. - if let Some(seed_hash) = self.pending_funding_address_request.take() { - self.funding_address_request_in_flight = true; - pending_tasks.push(BackendTask::WalletTask( - WalletTask::GenerateReceiveAddress { seed_hash }, - )); - } + if request_asset_lock_balance + && let Some(seed_hash) = self + .selected_wallet + .as_ref() + .and_then(|wallet| wallet.read().ok().map(|wallet| wallet.seed_hash())) + { + let (snapshot_generation, input_state, utxo_revision) = + self.app_context.asset_lock_probe_snapshot(&seed_hash); + if let Some(task) = self.asset_lock_balance.ensure_requested( + seed_hash, + snapshot_generation, + input_state, + utxo_revision, + ) { + pending_tasks.push(task); + } + } - match pending_tasks.pop() { - None => {} - Some(task) if pending_tasks.is_empty() => action |= AppAction::BackendTask(task), - Some(task) => { - pending_tasks.push(task); - action |= - AppAction::BackendTasks(pending_tasks, BackendTasksExecutionMode::Concurrent) + // Derive the "Receive a new deposit" address off the UI thread; the QR + // view queues this when it has no address yet. + if let Some(seed_hash) = self.pending_funding_address_request.take() { + self.funding_address_request_in_flight = true; + pending_tasks.push(BackendTask::WalletTask( + WalletTask::GenerateReceiveAddress { seed_hash }, + )); } + + action = append_concurrent_backend_tasks(action, pending_tasks); } action @@ -1777,7 +1906,57 @@ impl ScreenLike for AddNewIdentityScreen { #[cfg(test)] mod funding_method_tests { - use super::format_wallet_picker_label; + use super::*; + use crate::context::test_support::{test_app_context, test_app_context_for_network}; + use crate::model::amount::DASH_DECIMAL_PLACES; + use crate::ui::Screen; + use crate::wallet_backend::AssetLockInputState; + use dash_sdk::dpp::dashcore::{Network, OutPoint, Txid, hashes::Hash}; + + fn different_asset_lock_inputs(seed_byte: u8) -> AssetLockInputState { + AssetLockInputState::from_inputs([( + OutPoint::new(Txid::from_byte_array([seed_byte; 32]), 0), + 1, + )]) + } + + fn wallet_balance_screen( + seed_byte: u8, + ) -> (AddNewIdentityScreen, WalletSeedHash, tempfile::TempDir) { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let app_context = test_app_context(temp_dir.path()); + let seed = [seed_byte; 64]; + let wallet = Arc::new(RwLock::new( + Wallet::new_from_seed(seed, Network::Testnet, None, None).expect("wallet"), + )); + let seed_hash = wallet.read_recover().seed_hash(); + let master = IdentityKeyEntry::from_seed( + &wallet.read_recover(), + &seed, + Network::Testnet, + 0, + 0, + KeyType::ECDSA_HASH160, + Purpose::AUTHENTICATION, + SecurityLevel::MASTER, + None, + ) + .expect("master identity key"); + let mut screen = AddNewIdentityScreen::new(&app_context); + screen.selected_wallet = Some(wallet); + screen.identity_keys = IdentityKeySpecs::new(Some(master), Vec::new()); + screen.funding_amount = Some(Amount::new(CREDITS_PER_DUFF, DASH_DECIMAL_PLACES)); + (screen, seed_hash, temp_dir) + } + + fn asset_lock_request_id(task: Option) -> u64 { + match task { + Some(BackendTask::WalletTask(WalletTask::GetAssetLockMaxAmount { + request_id, .. + })) => request_id, + other => panic!("expected asset-lock maximum request, got {other:?}"), + } + } /// The picker label pairs the wallet alias with its spendable balance, /// rendered in DASH, so the user can compare wallets before choosing one. @@ -1806,4 +1985,211 @@ mod funding_method_tests { assert!(label.contains(" — "), "uses an em-dash separator: {label}"); assert!(label.ends_with(" DASH"), "shows the DASH unit: {label}"); } + + #[test] + fn receive_deposit_dispatch_rejects_amount_above_deposit_address_balance() { + const DEPOSIT_ADDRESS_DUFFS: u64 = 10_000_000; + const REQUESTED_DUFFS: u64 = 20_000_000; + const WALLET_CEILING_DUFFS: u64 = 100_000_000; + + let temp_dir = tempfile::tempdir().expect("temp dir"); + let app_context = test_app_context(temp_dir.path()); + let seed = [0x31; 64]; + let wallet = Arc::new(RwLock::new( + Wallet::new_from_seed(seed, Network::Testnet, None, None).expect("wallet"), + )); + let seed_hash = wallet.read_recover().seed_hash(); + let master = IdentityKeyEntry::from_seed( + &wallet.read_recover(), + &seed, + Network::Testnet, + 0, + 0, + KeyType::ECDSA_HASH160, + Purpose::AUTHENTICATION, + SecurityLevel::MASTER, + None, + ) + .expect("master identity key"); + let mut screen = AddNewIdentityScreen::new(&app_context); + screen.selected_wallet = Some(wallet); + screen.identity_keys = IdentityKeySpecs::new(Some(master), Vec::new()); + screen.funding_address_balance_duffs = DEPOSIT_ADDRESS_DUFFS; + screen.funding_amount = Some(Amount::new( + REQUESTED_DUFFS * CREDITS_PER_DUFF, + DASH_DECIMAL_PLACES, + )); + let (generation, final_funds, revision) = app_context.asset_lock_probe_snapshot(&seed_hash); + let request_id = asset_lock_request_id(screen.asset_lock_balance.ensure_requested( + seed_hash, + generation, + final_funds.clone(), + revision, + )); + screen.asset_lock_balance.store( + seed_hash, + generation, + request_id, + WALLET_CEILING_DUFFS, + final_funds, + false, + ); + + assert!(matches!( + screen.register_identity_clicked(FundingMethod::ReceiveDeposit), + AppAction::None + )); + } + + #[test] + fn register_identity_dispatch_rejects_quote_for_stale_utxo_composition() { + let (mut screen, seed_hash, _temp_dir) = wallet_balance_screen(0x35); + let (_, current_final_funds, current_revision) = + screen.app_context.asset_lock_probe_snapshot(&seed_hash); + let stale_inputs = different_asset_lock_inputs(0x35); + + let request_id = asset_lock_request_id(screen.asset_lock_balance.ensure_requested( + seed_hash, + 7, + current_final_funds, + current_revision, + )); + screen + .asset_lock_balance + .store(seed_hash, 7, request_id, 10_000_000, stale_inputs, false); + + assert!(matches!( + screen.register_identity_clicked(FundingMethod::UseWalletBalance), + AppAction::None + )); + let ctx = screen.app_context.egui_ctx(); + assert!(MessageBanner::has_global(ctx)); + MessageBanner::clear_global_message( + ctx, + "Your wallet's available amount is still being checked. Wait a moment and try again.", + ); + assert!( + !MessageBanner::has_global(ctx), + "stale composition must surface the loading warning rather than dispatch" + ); + } + + #[test] + fn register_identity_dispatch_distinguishes_failed_probe_from_loading() { + let (mut screen, seed_hash, _temp_dir) = wallet_balance_screen(0x36); + let ctx = screen.app_context.egui_ctx().clone(); + let (generation, final_funds, revision) = + screen.app_context.asset_lock_probe_snapshot(&seed_hash); + let request_id = asset_lock_request_id(screen.asset_lock_balance.ensure_requested( + seed_hash, + generation, + final_funds, + revision, + )); + + assert!(matches!( + screen.register_identity_clicked(FundingMethod::UseWalletBalance), + AppAction::None + )); + assert!(MessageBanner::has_global(&ctx)); + MessageBanner::clear_global_message( + &ctx, + "Your wallet's available amount is still being checked. Wait a moment and try again.", + ); + assert!( + !MessageBanner::has_global(&ctx), + "loading dispatch must use the loading-specific warning" + ); + + screen + .asset_lock_balance + .mark_loading_failed(&seed_hash, generation, request_id); + assert!(screen.asset_lock_balance.is_failed(&seed_hash)); + + assert!(matches!( + screen.register_identity_clicked(FundingMethod::UseWalletBalance), + AppAction::None + )); + assert!(MessageBanner::has_global(&ctx)); + MessageBanner::clear_global_message( + &ctx, + "The available amount could not be checked. Use Retry and try again.", + ); + assert!( + !MessageBanner::has_global(&ctx), + "failed dispatch must use the failed-specific retry warning" + ); + } + + #[test] + fn network_switch_and_refresh_invalidate_asset_lock_balance() { + let old_dir = tempfile::tempdir().expect("old context dir"); + let new_dir = tempfile::tempdir().expect("new context dir"); + let old_context = test_app_context(old_dir.path()); + let new_context = test_app_context_for_network(new_dir.path(), Network::Mainnet); + let wallet = Arc::new(RwLock::new( + Wallet::new_from_seed([0x33; 64], Network::Testnet, None, None).expect("wallet"), + )); + let seed_hash = wallet.read_recover().seed_hash(); + let mut screen = AddNewIdentityScreen::new(&old_context); + screen.selected_wallet = Some(wallet); + let request_id = asset_lock_request_id(screen.asset_lock_balance.ensure_requested( + seed_hash, + 7, + AssetLockInputState::default(), + 1, + )); + screen.asset_lock_balance.store( + seed_hash, + 7, + request_id, + 900, + AssetLockInputState::default(), + false, + ); + + let mut screen = Screen::AddNewIdentityScreen(screen); + screen.change_context(new_context.clone()); + let Screen::AddNewIdentityScreen(mut screen) = screen else { + panic!("screen variant changed"); + }; + assert!(Arc::ptr_eq(&screen.app_context, &new_context)); + assert_eq!(screen.app_context.network(), Network::Mainnet); + assert!(screen.selected_wallet.is_none()); + assert_eq!(screen.asset_lock_balance.get(&seed_hash), None); + + let request_id = asset_lock_request_id(screen.asset_lock_balance.ensure_requested( + seed_hash, + 8, + AssetLockInputState::default(), + 1, + )); + screen.asset_lock_balance.store( + seed_hash, + 8, + request_id, + 800, + AssetLockInputState::default(), + false, + ); + screen.refresh_on_arrival(); + assert_eq!(screen.asset_lock_balance.get(&seed_hash), None); + + let request_id = asset_lock_request_id(screen.asset_lock_balance.ensure_requested( + seed_hash, + 9, + AssetLockInputState::default(), + 1, + )); + screen.asset_lock_balance.store( + seed_hash, + 9, + request_id, + 700, + AssetLockInputState::default(), + false, + ); + screen.refresh(); + assert_eq!(screen.asset_lock_balance.get(&seed_hash), None); + } } diff --git a/src/ui/identities/funding_common.rs b/src/ui/identities/funding_common.rs index bc80a9117..9cdeb23b1 100644 --- a/src/ui/identities/funding_common.rs +++ b/src/ui/identities/funding_common.rs @@ -158,6 +158,11 @@ pub fn max_amount_after_fee_reserve(spendable_duffs: u64, fee_credits: u64) -> u .saturating_sub(fee_credits) } +/// Bound a received deposit by both its address balance and the wallet ceiling. +pub fn receive_deposit_ceiling_duffs(wallet_ceiling_duffs: u64, address_balance_duffs: u64) -> u64 { + wallet_ceiling_duffs.min(address_balance_duffs) +} + /// Round a DASH amount up to 4 decimal places — the precision of the `dash:` /// payment URI. Rounding up (never to nearest) guarantees the amount shown in /// the hint and encoded in the QR never understates the true minimum needed. @@ -475,6 +480,12 @@ mod tests { assert_eq!(max_amount_after_fee_reserve(u64::MAX, 0), u64::MAX); } + #[test] + fn receive_deposit_ceiling_uses_the_lower_available_balance() { + assert_eq!(receive_deposit_ceiling_duffs(10_000, 4_000), 4_000); + assert_eq!(receive_deposit_ceiling_duffs(4_000, 10_000), 4_000); + } + /// A wallet with spendable balance defaults to the recommended path, /// pre-selected and ready to go. #[test] diff --git a/src/ui/identities/top_up_identity_screen/by_receive_deposit.rs b/src/ui/identities/top_up_identity_screen/by_receive_deposit.rs index 8c49c00b1..f37862b4e 100644 --- a/src/ui/identities/top_up_identity_screen/by_receive_deposit.rs +++ b/src/ui/identities/top_up_identity_screen/by_receive_deposit.rs @@ -135,6 +135,10 @@ impl TopUpIdentityScreen { let mut action = AppAction::None; self.reconcile_funding_deposit(); let step = self.current_step(); + let seed_hash = self + .wallet + .as_ref() + .and_then(|wallet| wallet.read().ok().map(|wallet| wallet.seed_hash())); if step == WalletFundedScreenStep::WaitingOnFunds { ui.heading(format!( @@ -161,6 +165,38 @@ impl TopUpIdentityScreen { // committed to the pending transaction, so the input's max recomputes to // 0 and would show a stale "exceeds maximum" error over a succeeding op. if step == WalletFundedScreenStep::FundsReceived { + let Some(seed_hash) = seed_hash else { + if ui.button("Choose a different funding method").clicked() { + self.reset_to_choose_funding(); + } + return action; + }; + let failed = self.asset_lock_balance.is_failed(&seed_hash); + let loading = self.asset_lock_quote_is_loading(&seed_hash); + if failed || loading { + ui.label(if failed { + "The available amount could not be checked." + } else { + "Checking the available amount…" + }); + if self.asset_lock_balance.should_offer_retry(&seed_hash) + && ui.button("Retry available amount check").clicked() + { + self.asset_lock_balance.invalidate_one(&seed_hash); + } + if ui.button("Choose a different funding method").clicked() { + self.reset_to_choose_funding(); + } + return action; + } + if self.asset_lock_balance.should_offer_retry(&seed_hash) { + ui.label( + "The amount shown is safe but may be lower than your full available amount.", + ); + if ui.button("Retry available amount check").clicked() { + self.asset_lock_balance.invalidate_one(&seed_hash); + } + } self.top_up_funding_amount_input(ui); let has_valid_amount = self.funding_amount_exact.is_some_and(|d| d > 0); diff --git a/src/ui/identities/top_up_identity_screen/by_using_unused_balance.rs b/src/ui/identities/top_up_identity_screen/by_using_unused_balance.rs index d3b8063b8..bd2ef2a6f 100644 --- a/src/ui/identities/top_up_identity_screen/by_using_unused_balance.rs +++ b/src/ui/identities/top_up_identity_screen/by_using_unused_balance.rs @@ -17,10 +17,11 @@ impl TopUpIdentityScreen { } }; - let spendable_balance: u64 = self - .app_context - .snapshot_balance(&wallet.seed_hash()) - .spendable(); + let seed_hash = wallet.seed_hash(); + let spendable_balance = self + .asset_lock_balance + .get(&seed_hash) + .unwrap_or_else(|| self.app_context.snapshot_balance(&seed_hash).spendable()); let dash_balance = spendable_balance as f64 * 1e-8; // Convert to DASH units @@ -41,10 +42,7 @@ impl TopUpIdentityScreen { fn render_insufficient_wallet_balance_banner(&self, ui: &mut egui::Ui) -> Option { let selected_wallet = self.wallet.as_ref()?; let spendable_duffs = match selected_wallet.read() { - Ok(w) => self - .app_context - .snapshot_balance(&w.seed_hash()) - .spendable(), + Ok(w) => self.asset_lock_balance.get(&w.seed_hash())?, Err(_) => { ui.label("Wallet is busy. Try again in a moment."); return Some(AppAction::None); @@ -93,8 +91,38 @@ impl TopUpIdentityScreen { self.show_wallet_balance(ui); ui.add_space(5.0); + let seed_hash = self + .wallet + .as_ref() + .and_then(|wallet| wallet.read().ok().map(|wallet| wallet.seed_hash())); + let Some(seed_hash) = seed_hash else { + return action; + }; + let failed = self.asset_lock_balance.is_failed(&seed_hash); + let loading = self.asset_lock_quote_is_loading(&seed_hash); + if failed || loading { + ui.label(if failed { + "The available amount could not be checked." + } else { + "Checking the available amount…" + }); + if self.asset_lock_balance.should_offer_retry(&seed_hash) + && ui.button("Retry available amount check").clicked() + { + self.asset_lock_balance.invalidate_one(&seed_hash); + } + return action; + } + if self.asset_lock_balance.should_offer_retry(&seed_hash) { + ui.label("The amount shown is safe but may be lower than your full available amount."); + if ui.button("Retry available amount check").clicked() { + self.asset_lock_balance.invalidate_one(&seed_hash); + } + } + if let Some(insufficient_action) = self.render_insufficient_wallet_balance_banner(ui) { - return insufficient_action; + action |= insufficient_action; + return action; } self.top_up_funding_amount_input(ui); diff --git a/src/ui/identities/top_up_identity_screen/mod.rs b/src/ui/identities/top_up_identity_screen/mod.rs index 0a2711e8b..cf8f2f9b1 100644 --- a/src/ui/identities/top_up_identity_screen/mod.rs +++ b/src/ui/identities/top_up_identity_screen/mod.rs @@ -4,7 +4,7 @@ mod by_using_unused_asset_lock; mod by_using_unused_balance; mod success_screen; -use crate::app::{AppAction, BackendTasksExecutionMode}; +use crate::app::AppAction; use crate::backend_task::core::CoreItem; use crate::backend_task::error::TaskError; use crate::backend_task::identity::{IdentityTask, IdentityTopUpInfo, TopUpIdentityFundingMethod}; @@ -12,7 +12,8 @@ use crate::backend_task::wallet::WalletTask; use crate::backend_task::{BackendTask, BackendTaskContext, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::amount::Amount; -use crate::model::fee_estimation::format_credits_as_dash; +use crate::model::asset_lock::{AssetLockAmountError, validate_asset_lock_amount}; +use crate::model::fee_estimation::{format_credits_as_dash, format_duffs_as_dash}; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::{Wallet, WalletSeedHash}; use crate::ui::components::MessageBanner; @@ -27,11 +28,13 @@ use crate::ui::components::wallet_unlock_popup::{ }; use crate::ui::identities::funding_common::{ FundingMethod, WalletFundedScreenStep, default_funding_state, deposit_event_outcome, - max_amount_after_fee_reserve, spendable_covers_minimum, step_after_task_failure, - wallet_selection_combo, + max_amount_after_fee_reserve, receive_deposit_ceiling_duffs, spendable_covers_minimum, + step_after_task_failure, wallet_selection_combo, +}; +use crate::ui::state::{AssetLockBalanceCache, TrackedAssetLockCache}; +use crate::ui::{ + MessageType, ScreenLike, append_concurrent_backend_tasks, can_append_concurrent_backend_tasks, }; -use crate::ui::state::TrackedAssetLockCache; -use crate::ui::{MessageType, ScreenLike}; use dash_sdk::dashcore_rpc::dashcore::Address; use dash_sdk::dashcore_rpc::dashcore::transaction::special_transaction::TransactionPayload; use dash_sdk::dpp::address_funds::PlatformAddress; @@ -46,23 +49,6 @@ use std::sync::{Arc, RwLock}; const WALLET_SELECTION_TOOLTIP: &str = "Choose the wallet that will supply or receive the Dash used to add funds to this identity."; -fn pending_backend_tasks_action( - mut lock_fetches: Vec, - funding_address_request: Option, -) -> AppAction { - if let Some(task) = funding_address_request { - lock_fetches.push(task); - } - match lock_fetches.pop() { - None => AppAction::None, - Some(task) if lock_fetches.is_empty() => AppAction::BackendTask(task), - Some(task) => { - lock_fetches.push(task); - AppAction::BackendTasks(lock_fetches, BackendTasksExecutionMode::Concurrent) - } - } -} - pub struct TopUpIdentityScreen { pub identity: QualifiedIdentity, step: Arc>, @@ -106,6 +92,7 @@ pub struct TopUpIdentityScreen { /// Task System. Backs the funding-method gate, the wallet selector, and the /// asset-lock picker. asset_lock_cache: TrackedAssetLockCache, + asset_lock_balance: AssetLockBalanceCache, } impl TopUpIdentityScreen { @@ -135,6 +122,7 @@ impl TopUpIdentityScreen { platform_top_up_amount_input: None, completed_fee_result: None, asset_lock_cache: TrackedAssetLockCache::default(), + asset_lock_balance: AssetLockBalanceCache::default(), } } @@ -164,8 +152,51 @@ impl TopUpIdentityScreen { .unwrap_or(FundingMethod::NoSelection) } - /// Whether `wallet` currently has the resources the given funding method - /// needs. A busy wallet lock reads as "no resources" rather than panicking. + /// Whether the loaded builder ceiling covers the top-up minimum. + /// An unloaded quote does not block the funding option. + fn wallet_balance_can_afford_top_up(&self, seed_hash: &WalletSeedHash) -> bool { + let minimum = self.app_context.fee_estimator().estimate_identity_topup(); + self.asset_lock_balance + .get(seed_hash) + .is_none_or(|ceiling| spendable_covers_minimum(ceiling, minimum)) + } + + /// Whether the builder ceiling for the wallet's current spendable inputs + /// is still being checked (no quote yet, or the quote predates an input + /// change and is being revalidated). + fn asset_lock_quote_is_loading(&self, seed_hash: &WalletSeedHash) -> bool { + let (_, input_state, _) = self.app_context.asset_lock_probe_snapshot(seed_hash); + self.asset_lock_balance + .get_current(seed_hash, &input_state) + .is_none() + } + + /// Builder ceiling for Max and dispatch validation — one accessor for + /// both, valid only while the quote matches current wallet inputs, so Max + /// can never offer an amount validation would refuse. + fn current_validation_ceiling_duffs(&self, funding_method: FundingMethod) -> Option { + let seed_hash = self + .wallet + .as_ref() + .and_then(|wallet| wallet.read().ok()) + .map(|wallet| wallet.seed_hash())?; + let (_, input_state, _) = self.app_context.asset_lock_probe_snapshot(&seed_hash); + let wallet_ceiling_duffs = self + .asset_lock_balance + .get_current(&seed_hash, &input_state)?; + + match funding_method { + FundingMethod::UseWalletBalance => Some(wallet_ceiling_duffs), + FundingMethod::ReceiveDeposit => Some(receive_deposit_ceiling_duffs( + wallet_ceiling_duffs, + self.funding_address_balance_duffs, + )), + _ => None, + } + } + + /// Whether `wallet` remains eligible; an unloaded ceiling does not block it. + /// A busy wallet lock reads as ineligible rather than panicking. fn wallet_has_resources_for( &self, wallet: &Arc>, @@ -176,7 +207,7 @@ impl TopUpIdentityScreen { }; match method { FundingMethod::UseWalletBalance => { - self.app_context.snapshot_has_balance(&w.seed_hash()) + self.wallet_balance_can_afford_top_up(&w.seed_hash()) } FundingMethod::UseUnusedAssetLock => self.asset_lock_cache.has_unused(&w.seed_hash()), _ => true, @@ -228,16 +259,7 @@ impl TopUpIdentityScreen { let can_afford = wallet .read() .ok() - .map(|w| { - let spendable = self - .app_context - .snapshot_balance(&w.seed_hash()) - .spendable(); - let minimum = - self.app_context.fee_estimator().estimate_identity_topup(); - spendable_covers_minimum(spendable, minimum) - }) - .unwrap_or(false); + .is_some_and(|w| self.wallet_balance_can_afford_top_up(&w.seed_hash())); let (recommended, _) = default_funding_state(can_afford); if let Ok(mut m) = self.funding_method.write() { *m = recommended; @@ -263,6 +285,7 @@ impl TopUpIdentityScreen { if let Some(wallet) = selected_wallet_update { self.wallet = Some(wallet); + self.asset_lock_balance.invalidate(); self.wallet_open_attempted = false; self.funding_address = None; self.pending_funding_address_request = None; @@ -315,6 +338,23 @@ impl TopUpIdentityScreen { self.funding_amount.clear(); } + /// Reset wallet- and network-bound state after changing contexts. + pub(crate) fn reset_for_network_switch(&mut self) { + self.wallet = None; + self.funding_asset_lock = None; + self.reset_to_choose_funding(); + self.wallet_unlock_popup = WalletUnlockPopup::new(); + self.wallet_open_attempted = false; + self.copied_to_clipboard = None; + self.show_pop_up_info = None; + self.selected_platform_address = None; + self.platform_top_up_amount = None; + self.platform_top_up_amount_input = None; + self.completed_fee_result = None; + self.asset_lock_cache.invalidate(); + self.asset_lock_balance.invalidate(); + } + fn render_funding_method(&mut self, ui: &mut egui::Ui) { let funding_method_arc = self.funding_method.clone(); let Ok(mut funding_method) = funding_method_arc.write() else { @@ -340,7 +380,7 @@ impl TopUpIdentityScreen { { has_unused_asset_lock = true; } - if self.app_context.snapshot_has_balance(&seed_hash) { + if self.wallet_balance_can_afford_top_up(&seed_hash) { has_balance = true; } if wallet.total_platform_balance() > 0 { @@ -474,20 +514,41 @@ impl TopUpIdentityScreen { if amount == 0 { return AppAction::None; } - if funding_method == FundingMethod::ReceiveDeposit { - let fee_credits = self.app_context.fee_estimator().estimate_identity_topup(); - let available_credits = max_amount_after_fee_reserve( - self.funding_address_balance_duffs, - fee_credits, - ); - if amount.saturating_mul(CREDITS_PER_DUFF) > available_credits { - MessageBanner::set_global( - self.app_context.egui_ctx(), - "That deposit cannot cover this amount. Wait for more funds or choose a smaller amount.", - MessageType::Warning, - ); + let Some(max_amount) = self.current_validation_ceiling_duffs(funding_method) else { + let Ok(wallet) = selected_wallet.read() else { return AppAction::None; - } + }; + MessageBanner::set_global( + self.app_context.egui_ctx(), + self.asset_lock_balance + .validation_unavailable_message(&wallet.seed_hash()), + MessageType::Warning, + ); + return AppAction::None; + }; + let identity_fee_duffs = self + .app_context + .fee_estimator() + .estimate_identity_topup() + .div_ceil(CREDITS_PER_DUFF); + if let Err(error) = + validate_asset_lock_amount(amount, identity_fee_duffs, max_amount) + { + let maximum_amount_duffs = match error { + AssetLockAmountError::Overflow => max_amount, + AssetLockAmountError::ExceedsMaximum { + maximum_amount_duffs, + } => maximum_amount_duffs, + }; + MessageBanner::set_global( + self.app_context.egui_ctx(), + format!( + "You can transfer up to {} right now. Choose a smaller amount or wait for more funds.", + format_duffs_as_dash(maximum_amount_duffs) + ), + MessageType::Warning, + ); + return AppAction::None; } let identity_input = IdentityTopUpInfo { qualified_identity: self.identity.clone(), @@ -518,41 +579,25 @@ impl TopUpIdentityScreen { fn top_up_funding_amount_input(&mut self, ui: &mut egui::Ui) { let funding_method = self.current_funding_method(); - - // Apply the max-amount restriction for wallet-balance funding and for a - // received deposit (which also spends from the wallet balance). - let (max_amount, show_max_button, fee_hint) = if matches!( - funding_method, - FundingMethod::UseWalletBalance | FundingMethod::ReceiveDeposit - ) { - let max_spendable_duffs = if funding_method == FundingMethod::ReceiveDeposit { - self.funding_address_balance_duffs + let available_ceiling_duffs = self.current_validation_ceiling_duffs(funding_method); + + let (max_amount, show_max_button, fee_hint) = + if let Some(available_ceiling_duffs) = available_ceiling_duffs { + let fee_estimator = self.app_context.fee_estimator(); + let estimated_fee = fee_estimator.estimate_identity_topup(); + let max_with_fee_reserved = + max_amount_after_fee_reserve(available_ceiling_duffs, estimated_fee); + ( + Some(max_with_fee_reserved), + true, + Some(format!( + "The estimated fee reserves about {}.", + format_credits_as_dash(estimated_fee), + )), + ) } else { - self.wallet - .as_ref() - .and_then(|w| w.read().ok()) - .map(|w| { - self.app_context - .snapshot_balance(&w.seed_hash()) - .spendable() - }) - .unwrap_or(0) + (None, false, None) }; - let fee_estimator = self.app_context.fee_estimator(); - let estimated_fee = fee_estimator.estimate_identity_topup(); - let max_with_fee_reserved = - max_amount_after_fee_reserve(max_spendable_duffs, estimated_fee); - ( - Some(max_with_fee_reserved), - true, - Some(format!( - "The estimated fee reserves about {}.", - format_credits_as_dash(estimated_fee), - )), - ) - } else { - (None, false, None) - }; // Lazy initialization of the AmountInput component let should_prefill = self.prefill_funding_amount; @@ -595,6 +640,14 @@ impl TopUpIdentityScreen { } impl ScreenLike for TopUpIdentityScreen { + fn refresh_on_arrival(&mut self) { + self.asset_lock_balance.invalidate(); + } + + fn refresh(&mut self) { + self.asset_lock_balance.invalidate(); + } + fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. if matches!(message_type, MessageType::Error | MessageType::Warning) { @@ -603,6 +656,15 @@ impl ScreenLike for TopUpIdentityScreen { } fn display_backend_task_error(&mut self, context: &BackendTaskContext, _error: &TaskError) { + if let Some((seed_hash, snapshot_generation, request_id)) = + context.asset_lock_max_amount_request() + { + self.asset_lock_balance.mark_loading_failed( + &seed_hash, + snapshot_generation, + request_id, + ); + } let selected_seed_hash = self .wallet .as_ref() @@ -614,7 +676,35 @@ impl ScreenLike for TopUpIdentityScreen { self.funding_address_request_failed = true; } } + + fn should_suppress_backend_task_error( + &self, + context: &BackendTaskContext, + _error: &TaskError, + ) -> bool { + context.asset_lock_max_amount_request().is_some() + } + fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { + if let BackendTaskSuccessResult::AssetLockMaxAmount { + seed_hash, + snapshot_generation, + request_id, + amount_duffs, + observed_inputs, + is_partial, + } = &backend_task_success_result + { + self.asset_lock_balance.store( + *seed_hash, + *snapshot_generation, + *request_id, + *amount_duffs, + observed_inputs.clone(), + *is_partial, + ); + return; + } if let BackendTaskSuccessResult::TrackedAssetLocks { seed_hash, locks } = backend_task_success_result { @@ -742,6 +832,7 @@ impl ScreenLike for TopUpIdentityScreen { crate::ui::RootScreenType::RootScreenIdentities, ); + let mut request_asset_lock_balance = false; action |= island_central_panel(ui, |ui| { let mut inner_action = AppAction::None; @@ -871,12 +962,14 @@ impl ScreenLike for TopUpIdentityScreen { inner_action |= self.render_ui_by_using_unused_asset_lock(ui, step_number); } FundingMethod::UseWalletBalance => { + request_asset_lock_balance = true; inner_action |= self.render_ui_by_using_unused_balance(ui, step_number); } FundingMethod::UsePlatformAddress => { inner_action |= self.render_ui_by_platform_address(ui, step_number); } FundingMethod::ReceiveDeposit => { + request_asset_lock_balance = true; inner_action |= self.render_ui_by_receive_deposit(ui, step_number); } } @@ -913,34 +1006,52 @@ impl ScreenLike for TopUpIdentityScreen { }); } - // Fetch tracked asset locks once per wallet (off the UI thread). The - // funding-method gate and wallet selector check every wallet, so all - // are requested together as one concurrent batch. - let seed_hashes: Vec<_> = self - .app_context - .wallets - .read() - .map(|wallets| { - wallets - .values() - .filter_map(|w| w.read().ok().map(|g| g.seed_hash())) - .collect() - }) - .unwrap_or_default(); - let lock_fetches = self.asset_lock_cache.ensure_requested_many(seed_hashes); - - // Derive the "Receive a new deposit" address off the UI thread; the QR - // view queues this when it has no address yet. - let funding_address_request = - self.pending_funding_address_request - .take() - .map(|seed_hash| { - BackendTask::WalletTask(WalletTask::GenerateReceiveAddress { seed_hash }) - }); - if funding_address_request.is_some() { - self.funding_address_request_in_flight = true; + if can_append_concurrent_backend_tasks(&action) { + // Fetch tracked asset locks once per wallet (off the UI thread). The + // funding-method gate and wallet selector check every wallet, so all + // are requested together as one concurrent batch. + let seed_hashes: Vec<_> = self + .app_context + .wallets + .read() + .map(|wallets| { + wallets + .values() + .filter_map(|w| w.read().ok().map(|g| g.seed_hash())) + .collect() + }) + .unwrap_or_default(); + let mut pending_tasks = self.asset_lock_cache.ensure_requested_many(seed_hashes); + + if request_asset_lock_balance + && let Some(seed_hash) = self + .wallet + .as_ref() + .and_then(|wallet| wallet.read().ok().map(|wallet| wallet.seed_hash())) + { + let (snapshot_generation, input_state, utxo_revision) = + self.app_context.asset_lock_probe_snapshot(&seed_hash); + if let Some(task) = self.asset_lock_balance.ensure_requested( + seed_hash, + snapshot_generation, + input_state, + utxo_revision, + ) { + pending_tasks.push(task); + } + } + + // Derive the "Receive a new deposit" address off the UI thread; the QR + // view queues this when it has no address yet. + if let Some(seed_hash) = self.pending_funding_address_request.take() { + self.funding_address_request_in_flight = true; + pending_tasks.push(BackendTask::WalletTask( + WalletTask::GenerateReceiveAddress { seed_hash }, + )); + } + + action = append_concurrent_backend_tasks(action, pending_tasks); } - action |= pending_backend_tasks_action(lock_fetches, funding_address_request); action } @@ -949,26 +1060,92 @@ impl ScreenLike for TopUpIdentityScreen { #[cfg(test)] mod tests { use super::*; + use crate::app::BackendTasksExecutionMode; + use crate::context::test_support::{test_app_context, test_app_context_for_network}; + use crate::model::qualified_identity::encrypted_key_storage::KeyStorage; + use crate::model::qualified_identity::{IdentityStatus, IdentityType}; + use crate::ui::Screen; + use crate::wallet_backend::AssetLockInputState; + use dash_sdk::dpp::dashcore::{Network, OutPoint, Txid, hashes::Hash}; + use dash_sdk::dpp::identity::Identity; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::Identifier; + use std::collections::BTreeMap; + + fn different_asset_lock_inputs(seed_byte: u8) -> AssetLockInputState { + AssetLockInputState::from_inputs([( + OutPoint::new(Txid::from_byte_array([seed_byte; 32]), 0), + 1, + )]) + } + + fn wallet_balance_screen( + seed_byte: u8, + ) -> (TopUpIdentityScreen, WalletSeedHash, tempfile::TempDir) { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let app_context = test_app_context(temp_dir.path()); + let wallet = Arc::new(RwLock::new( + Wallet::new_from_seed([seed_byte; 64], Network::Testnet, None, None).expect("wallet"), + )); + let seed_hash = wallet.read().expect("wallet lock").seed_hash(); + let mut screen = TopUpIdentityScreen::new(test_identity(Network::Testnet), &app_context); + screen.wallet = Some(wallet); + screen.funding_amount_exact = Some(1); + (screen, seed_hash, temp_dir) + } + + fn asset_lock_request_id(task: Option) -> u64 { + match task { + Some(BackendTask::WalletTask(WalletTask::GetAssetLockMaxAmount { + request_id, .. + })) => request_id, + other => panic!("expected asset-lock maximum request, got {other:?}"), + } + } + + fn test_identity(network: Network) -> QualifiedIdentity { + QualifiedIdentity { + identity: Identity::new_with_id_and_keys( + Identifier::random(), + BTreeMap::new(), + PlatformVersion::latest(), + ) + .expect("identity"), + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::User, + alias: None, + private_keys: KeyStorage::default(), + dpns_names: Vec::new(), + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: Some(0), + top_ups: BTreeMap::new(), + status: IdentityStatus::Active, + network, + } + } #[test] - fn same_frame_dispatch_keeps_lock_fetches_and_receive_address_request() { + fn same_frame_dispatch_keeps_probe_and_other_backend_tasks() { let lock_seed_a = [1u8; 32]; - let lock_seed_b = [2u8; 32]; + let probe_seed = [2u8; 32]; let receive_seed = [3u8; 32]; - let action = pending_backend_tasks_action( + let action = append_concurrent_backend_tasks( + AppAction::BackendTask(BackendTask::WalletTask(WalletTask::ListTrackedAssetLocks { + seed_hash: lock_seed_a, + })), vec![ - BackendTask::WalletTask(WalletTask::ListTrackedAssetLocks { - seed_hash: lock_seed_a, + BackendTask::WalletTask(WalletTask::GetAssetLockMaxAmount { + seed_hash: probe_seed, + snapshot_generation: 9, + request_id: 17, }), - BackendTask::WalletTask(WalletTask::ListTrackedAssetLocks { - seed_hash: lock_seed_b, + BackendTask::WalletTask(WalletTask::GenerateReceiveAddress { + seed_hash: receive_seed, }), ], - Some(BackendTask::WalletTask( - WalletTask::GenerateReceiveAddress { - seed_hash: receive_seed, - }, - )), ); let AppAction::BackendTasks(tasks, BackendTasksExecutionMode::Concurrent) = action else { @@ -982,8 +1159,11 @@ mod tests { ))); assert!(tasks.iter().any(|task| matches!( task, - BackendTask::WalletTask(WalletTask::ListTrackedAssetLocks { seed_hash }) - if *seed_hash == lock_seed_b + BackendTask::WalletTask(WalletTask::GetAssetLockMaxAmount { + seed_hash, + snapshot_generation: 9, + request_id: 17, + }) if *seed_hash == probe_seed ))); assert!(tasks.iter().any(|task| matches!( task, @@ -991,4 +1171,194 @@ mod tests { if *seed_hash == receive_seed ))); } + + #[test] + fn receive_deposit_dispatch_rejects_amount_above_deposit_address_balance() { + const DEPOSIT_ADDRESS_DUFFS: u64 = 10_000_000; + const REQUESTED_DUFFS: u64 = 20_000_000; + const WALLET_CEILING_DUFFS: u64 = 100_000_000; + + let temp_dir = tempfile::tempdir().expect("temp dir"); + let app_context = test_app_context(temp_dir.path()); + let wallet = Arc::new(RwLock::new( + Wallet::new_from_seed([0x32; 64], Network::Testnet, None, None).expect("wallet"), + )); + let seed_hash = wallet.read().expect("wallet lock").seed_hash(); + let mut screen = TopUpIdentityScreen::new(test_identity(Network::Testnet), &app_context); + screen.wallet = Some(wallet); + screen.funding_address_balance_duffs = DEPOSIT_ADDRESS_DUFFS; + screen.funding_amount_exact = Some(REQUESTED_DUFFS); + let (generation, final_funds, revision) = app_context.asset_lock_probe_snapshot(&seed_hash); + let request_id = asset_lock_request_id(screen.asset_lock_balance.ensure_requested( + seed_hash, + generation, + final_funds.clone(), + revision, + )); + screen.asset_lock_balance.store( + seed_hash, + generation, + request_id, + WALLET_CEILING_DUFFS, + final_funds, + false, + ); + + assert!(matches!( + screen.top_up_identity_clicked(FundingMethod::ReceiveDeposit), + AppAction::None + )); + } + + #[test] + fn top_up_dispatch_rejects_quote_for_stale_utxo_composition() { + let (mut screen, seed_hash, _temp_dir) = wallet_balance_screen(0x37); + let (_, current_final_funds, current_revision) = + screen.app_context.asset_lock_probe_snapshot(&seed_hash); + let stale_inputs = different_asset_lock_inputs(0x37); + + let request_id = asset_lock_request_id(screen.asset_lock_balance.ensure_requested( + seed_hash, + 7, + current_final_funds, + current_revision, + )); + screen + .asset_lock_balance + .store(seed_hash, 7, request_id, 10_000_000, stale_inputs, false); + + assert!(matches!( + screen.top_up_identity_clicked(FundingMethod::UseWalletBalance), + AppAction::None + )); + let ctx = screen.app_context.egui_ctx(); + assert!(MessageBanner::has_global(ctx)); + MessageBanner::clear_global_message( + ctx, + "Your wallet's available amount is still being checked. Wait a moment and try again.", + ); + assert!( + !MessageBanner::has_global(ctx), + "stale composition must surface the loading warning rather than dispatch" + ); + } + + #[test] + fn top_up_dispatch_distinguishes_failed_probe_from_loading() { + let (mut screen, seed_hash, _temp_dir) = wallet_balance_screen(0x38); + let ctx = screen.app_context.egui_ctx().clone(); + let (generation, final_funds, revision) = + screen.app_context.asset_lock_probe_snapshot(&seed_hash); + let request_id = asset_lock_request_id(screen.asset_lock_balance.ensure_requested( + seed_hash, + generation, + final_funds, + revision, + )); + + assert!(matches!( + screen.top_up_identity_clicked(FundingMethod::UseWalletBalance), + AppAction::None + )); + assert!(MessageBanner::has_global(&ctx)); + MessageBanner::clear_global_message( + &ctx, + "Your wallet's available amount is still being checked. Wait a moment and try again.", + ); + assert!( + !MessageBanner::has_global(&ctx), + "loading dispatch must use the loading-specific warning" + ); + + screen + .asset_lock_balance + .mark_loading_failed(&seed_hash, generation, request_id); + assert!(screen.asset_lock_balance.is_failed(&seed_hash)); + + assert!(matches!( + screen.top_up_identity_clicked(FundingMethod::UseWalletBalance), + AppAction::None + )); + assert!(MessageBanner::has_global(&ctx)); + MessageBanner::clear_global_message( + &ctx, + "The available amount could not be checked. Use Retry and try again.", + ); + assert!( + !MessageBanner::has_global(&ctx), + "failed dispatch must use the failed-specific retry warning" + ); + } + + #[test] + fn network_switch_and_refresh_invalidate_asset_lock_balance() { + let old_dir = tempfile::tempdir().expect("old context dir"); + let new_dir = tempfile::tempdir().expect("new context dir"); + let old_context = test_app_context(old_dir.path()); + let new_context = test_app_context_for_network(new_dir.path(), Network::Mainnet); + let wallet = Arc::new(RwLock::new( + Wallet::new_from_seed([0x34; 64], Network::Testnet, None, None).expect("wallet"), + )); + let seed_hash = wallet.read().expect("wallet lock").seed_hash(); + let mut screen = TopUpIdentityScreen::new(test_identity(Network::Testnet), &old_context); + screen.wallet = Some(wallet); + let request_id = asset_lock_request_id(screen.asset_lock_balance.ensure_requested( + seed_hash, + 7, + AssetLockInputState::default(), + 1, + )); + screen.asset_lock_balance.store( + seed_hash, + 7, + request_id, + 900, + AssetLockInputState::default(), + false, + ); + + let mut screen = Screen::TopUpIdentityScreen(screen); + screen.change_context(new_context.clone()); + let Screen::TopUpIdentityScreen(mut screen) = screen else { + panic!("screen variant changed"); + }; + assert!(Arc::ptr_eq(&screen.app_context, &new_context)); + assert_eq!(screen.app_context.network(), Network::Mainnet); + assert!(screen.wallet.is_none()); + assert_eq!(screen.asset_lock_balance.get(&seed_hash), None); + + let request_id = asset_lock_request_id(screen.asset_lock_balance.ensure_requested( + seed_hash, + 8, + AssetLockInputState::default(), + 1, + )); + screen.asset_lock_balance.store( + seed_hash, + 8, + request_id, + 800, + AssetLockInputState::default(), + false, + ); + screen.refresh_on_arrival(); + assert_eq!(screen.asset_lock_balance.get(&seed_hash), None); + + let request_id = asset_lock_request_id(screen.asset_lock_balance.ensure_requested( + seed_hash, + 9, + AssetLockInputState::default(), + 1, + )); + screen.asset_lock_balance.store( + seed_hash, + 9, + request_id, + 700, + AssetLockInputState::default(), + false, + ); + screen.refresh(); + assert_eq!(screen.asset_lock_balance.get(&seed_hash), None); + } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 685464829..5c6d967cc 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,4 +1,5 @@ -use crate::app::AppAction; +use crate::app::{AppAction, BackendTasksExecutionMode}; +use crate::backend_task::BackendTask; use crate::backend_task::error::TaskError; use crate::backend_task::{BackendTaskContext, BackendTaskSuccessResult}; use crate::context::AppContext; @@ -74,6 +75,47 @@ use tokens::update_token_config::UpdateTokenConfigScreen; use tools::transition_visualizer_screen::TransitionVisualizerScreen; use wallets::add_new_wallet_screen::AddNewWalletScreen; +pub(crate) fn can_append_concurrent_backend_tasks(action: &AppAction) -> bool { + matches!( + action, + AppAction::None + | AppAction::BackendTask(_) + | AppAction::BackendTasks(_, BackendTasksExecutionMode::Concurrent) + ) +} + +pub(crate) fn append_concurrent_backend_tasks( + action: AppAction, + mut pending_tasks: Vec, +) -> AppAction { + if pending_tasks.is_empty() { + return action; + } + + match action { + AppAction::None => { + AppAction::BackendTasks(pending_tasks, BackendTasksExecutionMode::Concurrent) + } + AppAction::BackendTask(task) => { + pending_tasks.insert(0, task); + AppAction::BackendTasks(pending_tasks, BackendTasksExecutionMode::Concurrent) + } + AppAction::BackendTasks(mut tasks, BackendTasksExecutionMode::Concurrent) => { + tasks.append(&mut pending_tasks); + AppAction::BackendTasks(tasks, BackendTasksExecutionMode::Concurrent) + } + // Callers must gate on `can_append_concurrent_backend_tasks`; anything + // else cannot carry a batch, and the pending tasks are dropped. + other => { + tracing::warn!( + dropped_tasks = pending_tasks.len(), + "concurrent backend tasks were dropped: the frame action cannot carry a task batch" + ); + other + } + } +} + pub mod components; pub mod contracts_documents; pub mod dashpay; @@ -639,6 +681,16 @@ impl Screen { screen.app_context = app_context; return; } + Screen::AddNewIdentityScreen(screen) => { + screen.app_context = app_context; + screen.reset_for_network_switch(); + return; + } + Screen::TopUpIdentityScreen(screen) => { + screen.app_context = app_context; + screen.reset_for_network_switch(); + return; + } Screen::WalletSendScreen(screen) => { screen.app_context = app_context; // Drop all state bound to the old network's wallet (wallet, seed @@ -706,13 +758,11 @@ impl Screen { ContractVisualizerScreen, AddKeyScreen, DocumentQueryScreen, - AddNewIdentityScreen, RegisterDpnsNameScreen, RegisterDataContractScreen, UpdateDataContractScreen, DocumentActionScreen, GroupActionsScreen, - TopUpIdentityScreen, AddContractsScreen, ProofVisualizerScreen, DocumentVisualizerScreen, @@ -748,6 +798,8 @@ impl Screen { TransferScreen, WalletsBalancesScreen, ImportMnemonicScreen, + AddNewIdentityScreen, + TopUpIdentityScreen, WalletSendScreen, SingleKeyWalletSendScreen, CreateAssetLockScreen, diff --git a/src/ui/state/asset_lock_balance.rs b/src/ui/state/asset_lock_balance.rs new file mode 100644 index 000000000..001b23a63 --- /dev/null +++ b/src/ui/state/asset_lock_balance.rs @@ -0,0 +1,843 @@ +//! Per-screen cache of live asset-lock builder maximum amounts. + +use crate::backend_task::BackendTask; +use crate::backend_task::wallet::WalletTask; +use crate::model::wallet::WalletSeedHash; +use crate::wallet_backend::AssetLockInputState; +use std::collections::BTreeMap; +use std::time::{Duration, Instant}; + +const LOADING_VALIDATION_MESSAGE: &str = + "Your wallet's available amount is still being checked. Wait a moment and try again."; +const FAILED_VALIDATION_MESSAGE: &str = + "The available amount could not be checked. Use Retry and try again."; +const ASSET_LOCK_REQUEST_DEADLINE: Duration = Duration::from_secs(15); +/// Consecutive accepted replies whose observed composition failed to match the +/// dispatched key before automatic re-dispatch stops. The first mismatch gets +/// one automatic re-probe (it may be transient, e.g. an observation-deadline +/// expiry under momentary lock contention); a persistent divergence then waits +/// for a composition change or an explicit Retry. +const MAX_CONSECUTIVE_MISMATCHED_REPLIES: u8 = 2; + +#[derive(Clone)] +struct RequestKey { + generation: u64, + inputs: AssetLockInputState, + revision: u64, +} + +impl RequestKey { + fn matches(&self, generation: u64, inputs: &AssetLockInputState, revision: u64) -> bool { + self.generation == generation && self.inputs == *inputs && self.revision == revision + } + + fn matches_composition(&self, inputs: &AssetLockInputState, revision: u64) -> bool { + self.inputs == *inputs && self.revision == revision + } +} + +struct InFlight { + request_id: u64, + key: RequestKey, + started_at: Instant, +} + +struct LoadedQuote { + observed_inputs: AssetLockInputState, + amount_duffs: u64, +} + +struct FailedRequest { + key: RequestKey, +} + +/// Preserves result ordering within one generation sequence without making old +/// high-water marks or unresolved requests permanent dispatch barriers. +struct FetchState { + request_key: RequestKey, + loaded: Option, + in_flight: Option, + failed: Option, + retry_available: bool, + /// Consecutive replies for the current composition whose observed inputs + /// did not match it — see [`MAX_CONSECUTIVE_MISMATCHED_REPLIES`]. + mismatched_replies: u8, +} + +/// Async fetch state for asset-lock maximum amounts, keyed by wallet. +#[derive(Default)] +pub struct AssetLockBalanceCache { + states: BTreeMap, + next_request_id: u64, +} + +impl AssetLockBalanceCache { + /// Dispatch at most one live-builder query per relevant wallet snapshot. + /// + /// A final-funds or eligible-UTXO-composition change supersedes unresolved + /// work. Irrelevant generation churn keeps the existing request, while a + /// lower generation starts a fresh sequence after a counter reset. Replies + /// that persistently cannot match the published composition stop automatic + /// re-dispatch ([`MAX_CONSECUTIVE_MISMATCHED_REPLIES`]) until the + /// composition changes or [`Self::invalidate_one`] re-arms the wallet. + pub fn ensure_requested( + &mut self, + seed_hash: WalletSeedHash, + snapshot_generation: u64, + inputs: AssetLockInputState, + utxo_revision: u64, + ) -> Option { + self.ensure_requested_at( + seed_hash, + snapshot_generation, + inputs, + utxo_revision, + Instant::now(), + ) + } + + fn ensure_requested_at( + &mut self, + seed_hash: WalletSeedHash, + snapshot_generation: u64, + inputs: AssetLockInputState, + utxo_revision: u64, + now: Instant, + ) -> Option { + let state = self.states.entry(seed_hash).or_insert(FetchState { + request_key: RequestKey { + generation: snapshot_generation, + inputs: inputs.clone(), + revision: utxo_revision, + }, + loaded: None, + in_flight: None, + failed: None, + retry_available: false, + mismatched_replies: 0, + }); + let generation_restarted = snapshot_generation < state.request_key.generation; + let input_composition_changed = + inputs != state.request_key.inputs || utxo_revision != state.request_key.revision; + if generation_restarted || input_composition_changed { + state.request_key = RequestKey { + generation: snapshot_generation, + inputs: inputs.clone(), + revision: utxo_revision, + }; + state.in_flight = None; + state.failed = None; + state.retry_available = false; + state.mismatched_replies = 0; + if generation_restarted { + state.loaded = None; + } + } + + let in_flight_expired = state.in_flight.as_ref().is_some_and(|in_flight| { + in_flight.key.matches_composition(&inputs, utxo_revision) + && now + .checked_duration_since(in_flight.started_at) + .is_some_and(|elapsed| elapsed >= ASSET_LOCK_REQUEST_DEADLINE) + }); + if in_flight_expired { + state.in_flight = None; + state.retry_available = true; + state.request_key = RequestKey { + generation: snapshot_generation, + inputs: inputs.clone(), + revision: utxo_revision, + }; + } + + if state + .in_flight + .as_ref() + .is_some_and(|in_flight| in_flight.key.matches_composition(&inputs, utxo_revision)) + || state + .loaded + .as_ref() + .is_some_and(|loaded| loaded.observed_inputs == inputs) + || state.failed.as_ref().is_some_and(|failed| { + failed + .key + .matches(snapshot_generation, &inputs, utxo_revision) + }) + // Composition-keyed (not generation-keyed) on purpose: SPV event + // churn republishes the same composition under new generations and + // must not re-arm a probe that cannot match it. + || state.mismatched_replies >= MAX_CONSECUTIVE_MISMATCHED_REPLIES + { + return None; + } + let request_id = self.next_request_id.checked_add(1)?; + self.next_request_id = request_id; + state.request_key = RequestKey { + generation: snapshot_generation, + inputs: inputs.clone(), + revision: utxo_revision, + }; + state.in_flight = Some(InFlight { + request_id, + key: RequestKey { + generation: snapshot_generation, + inputs, + revision: utxo_revision, + }, + started_at: now, + }); + state.failed = None; + Some(BackendTask::WalletTask(WalletTask::GetAssetLockMaxAmount { + seed_hash, + snapshot_generation, + request_id, + })) + } + + /// Store the maximum returned by the live wallet backend. + pub fn store( + &mut self, + seed_hash: WalletSeedHash, + snapshot_generation: u64, + request_id: u64, + amount_duffs: u64, + observed_inputs: AssetLockInputState, + is_partial: bool, + ) { + let Some(state) = self.states.get_mut(&seed_hash) else { + return; + }; + if let Some(in_flight) = state.in_flight.as_ref() + && (in_flight.request_id, in_flight.key.generation) == (request_id, snapshot_generation) + { + let in_flight_key = in_flight.key.clone(); + state.in_flight = None; + if state.request_key.matches( + in_flight_key.generation, + &in_flight_key.inputs, + in_flight_key.revision, + ) { + let mismatched = observed_inputs != in_flight_key.inputs; + state.loaded = Some(LoadedQuote { + observed_inputs, + amount_duffs, + }); + if mismatched { + state.mismatched_replies = state.mismatched_replies.saturating_add(1); + if state.mismatched_replies >= MAX_CONSECUTIVE_MISMATCHED_REPLIES { + // Surface the dead end as a failed check so the UI + // offers Retry instead of an indefinite loading state. + state.failed = Some(FailedRequest { key: in_flight_key }); + } + } else { + state.mismatched_replies = 0; + state.failed = None; + } + state.retry_available = is_partial || mismatched; + } + } + } + + /// Mark one in-flight wallet query retryable after a backend failure. + pub fn mark_loading_failed( + &mut self, + seed_hash: &WalletSeedHash, + snapshot_generation: u64, + request_id: u64, + ) { + let Some(state) = self.states.get_mut(seed_hash) else { + return; + }; + if let Some(in_flight) = state.in_flight.as_ref() + && (in_flight.request_id, in_flight.key.generation) == (request_id, snapshot_generation) + { + let in_flight_key = in_flight.key.clone(); + state.in_flight = None; + if state.request_key.matches( + in_flight_key.generation, + &in_flight_key.inputs, + in_flight_key.revision, + ) { + state.failed = Some(FailedRequest { key: in_flight_key }); + state.retry_available = true; + } + } + } + + /// Return the most recent builder maximum, including during revalidation. + pub fn get(&self, seed_hash: &WalletSeedHash) -> Option { + self.states + .get(seed_hash) + .and_then(|state| state.loaded.as_ref().map(|loaded| loaded.amount_duffs)) + } + + /// Return a quote only when it matches the current validation inputs. + pub fn get_current( + &self, + seed_hash: &WalletSeedHash, + inputs: &AssetLockInputState, + ) -> Option { + self.states.get(seed_hash).and_then(|state| { + state + .loaded + .as_ref() + .filter(|loaded| loaded.observed_inputs == *inputs) + .map(|loaded| loaded.amount_duffs) + }) + } + + /// Whether the query failed and needs an explicit retry. + pub fn is_failed(&self, seed_hash: &WalletSeedHash) -> bool { + self.states.get(seed_hash).is_some_and(|state| { + state.failed.as_ref().is_some_and(|failed| { + failed.key.matches( + state.request_key.generation, + &state.request_key.inputs, + state.request_key.revision, + ) + }) + }) + } + + /// Whether the current loading/partial state should show a Retry button. + pub fn should_offer_retry(&self, seed_hash: &WalletSeedHash) -> bool { + self.is_failed(seed_hash) + || self + .states + .get(seed_hash) + .is_some_and(|state| state.retry_available) + } + + /// Explain why validation cannot use a builder quote yet. + pub fn validation_unavailable_message(&self, seed_hash: &WalletSeedHash) -> &'static str { + if self.is_failed(seed_hash) { + FAILED_VALIDATION_MESSAGE + } else { + LOADING_VALIDATION_MESSAGE + } + } + + /// Re-arm one wallet's query. + pub fn invalidate_one(&mut self, seed_hash: &WalletSeedHash) { + self.states.remove(seed_hash); + } + + /// Re-arm all wallet queries after a refresh or context change. + pub fn invalidate(&mut self) { + self.states.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::AssetLockBalanceCache; + use crate::backend_task::BackendTask; + use crate::backend_task::wallet::WalletTask; + use crate::wallet_backend::AssetLockInputState; + use crate::wallet_backend::DetWalletBalance; + use dash_sdk::dpp::dashcore::{OutPoint, Txid}; + use std::time::{Duration, Instant}; + + fn input_state(byte: u8, value: u64) -> AssetLockInputState { + AssetLockInputState::from_inputs([(OutPoint::new(Txid::from([byte; 32]), 0), value)]) + } + + fn snapshot_inputs(final_funds_duffs: u64, utxo_revision: u64) -> AssetLockInputState { + input_state(utxo_revision as u8, final_funds_duffs) + } + + fn store( + cache: &mut AssetLockBalanceCache, + seed_hash: [u8; 32], + snapshot_generation: u64, + request_id: u64, + amount_duffs: u64, + final_funds_duffs: u64, + utxo_revision: u64, + ) { + cache.store( + seed_hash, + snapshot_generation, + request_id, + amount_duffs, + snapshot_inputs(final_funds_duffs, utxo_revision), + false, + ); + } + + fn request( + cache: &mut AssetLockBalanceCache, + seed_hash: [u8; 32], + snapshot_generation: u64, + final_funds_duffs: u64, + utxo_revision: u64, + ) -> u64 { + match cache.ensure_requested( + seed_hash, + snapshot_generation, + snapshot_inputs(final_funds_duffs, utxo_revision), + utxo_revision, + ) { + Some(BackendTask::WalletTask(WalletTask::GetAssetLockMaxAmount { + seed_hash: requested_seed, + snapshot_generation: requested_generation, + request_id, + })) => { + assert_eq!(requested_seed, seed_hash); + assert_eq!(requested_generation, snapshot_generation); + request_id + } + other => panic!("expected asset-lock maximum request, got {other:?}"), + } + } + + #[test] + fn asset_lock_balance_cache_requeries_and_rejects_stale_results_after_snapshot_change() { + let seed_hash = [0x29; 32]; + let mut cache = AssetLockBalanceCache::default(); + + let request_7 = request(&mut cache, seed_hash, 7, 1_000, 1); + store(&mut cache, seed_hash, 7, request_7, 1_000, 1_000, 1); + assert_eq!(cache.get(&seed_hash), Some(1_000)); + + let request_8 = request(&mut cache, seed_hash, 8, 900, 2); + assert_eq!( + cache.get(&seed_hash), + Some(1_000), + "the last loaded value must remain displayable while generation 8 refreshes" + ); + assert!( + cache + .ensure_requested(seed_hash, 8, snapshot_inputs(900, 2), 2) + .is_none(), + "an in-flight refresh for the current generation must not dispatch twice" + ); + + store(&mut cache, seed_hash, 7, request_7, 1_000, 1_000, 1); + assert_eq!( + cache.get(&seed_hash), + Some(1_000), + "a stale response must not overwrite or erase the last displayable value" + ); + + store(&mut cache, seed_hash, 8, request_8, 900, 900, 2); + assert_eq!(cache.get(&seed_hash), Some(900)); + } + + #[test] + fn asset_lock_balance_cache_recovers_after_snapshot_generation_regression() { + let seed_hash = [0x2a; 32]; + let mut cache = AssetLockBalanceCache::default(); + + let old_request = request(&mut cache, seed_hash, 7, 1_000, 1); + store(&mut cache, seed_hash, 7, old_request, 1_000, 1_000, 1); + assert_eq!(cache.get(&seed_hash), Some(1_000)); + + let restarted_request = request(&mut cache, seed_hash, 2, 1_000, 1); + assert_eq!( + cache.get(&seed_hash), + None, + "a restarted generation sequence must discard data from the previous sequence" + ); + assert!( + cache + .ensure_requested(seed_hash, 2, snapshot_inputs(1_000, 1), 1) + .is_none(), + "the replacement request must still deduplicate its own generation" + ); + + store(&mut cache, seed_hash, 7, old_request, 2_000, 1_000, 1); + assert_eq!( + cache.get(&seed_hash), + None, + "a late result from before the generation restart must be ignored" + ); + store(&mut cache, seed_hash, 2, restarted_request, 800, 1_000, 1); + assert_eq!(cache.get(&seed_hash), Some(800)); + } + + #[test] + fn asset_lock_balance_cache_supersedes_stuck_in_flight_request_on_newer_snapshot() { + let seed_hash = [0x2b; 32]; + let mut cache = AssetLockBalanceCache::default(); + + let old_request = request(&mut cache, seed_hash, 4, 1_000, 1); + let current_request = request(&mut cache, seed_hash, 5, 900, 2); + assert!( + cache + .ensure_requested(seed_hash, 5, snapshot_inputs(900, 2), 2) + .is_none(), + "the superseding request must deduplicate its own generation" + ); + + store(&mut cache, seed_hash, 4, old_request, 1_000, 1_000, 1); + assert_eq!( + cache.get(&seed_hash), + None, + "the superseded request must not populate the cache" + ); + store(&mut cache, seed_hash, 5, current_request, 900, 900, 2); + assert_eq!(cache.get(&seed_hash), Some(900)); + } + + #[test] + fn asset_lock_balance_cache_ignores_generation_change_when_spendable_is_unchanged() { + let seed_hash = [0x2c; 32]; + let mut cache = AssetLockBalanceCache::default(); + + let request_id = request(&mut cache, seed_hash, 7, 1_000, 1); + assert!( + cache + .ensure_requested(seed_hash, 8, snapshot_inputs(1_000, 1), 1) + .is_none(), + "a generation-only change must not restart the live-builder probe" + ); + + store(&mut cache, seed_hash, 7, request_id, 900, 1_000, 1); + assert_eq!( + cache.get(&seed_hash), + Some(900), + "the original request must remain current after a generation-only change" + ); + } + + #[test] + fn asset_lock_balance_cache_requeries_when_spendable_changes() { + let seed_hash = [0x2d; 32]; + let mut cache = AssetLockBalanceCache::default(); + + request(&mut cache, seed_hash, 7, 1_000, 1); + request(&mut cache, seed_hash, 8, 1_500, 2); + } + + #[test] + fn asset_lock_balance_cache_requeries_when_unconfirmed_funds_become_final() { + let seed_hash = [0x2e; 32]; + let mut cache = AssetLockBalanceCache::default(); + let unconfirmed = DetWalletBalance { + confirmed: 0, + unconfirmed: 1_000, + total: 1_000, + }; + let confirmed = DetWalletBalance { + confirmed: 1_000, + unconfirmed: 0, + total: 1_000, + }; + + assert_eq!(unconfirmed.spendable(), confirmed.spendable()); + let unconfirmed_request = request(&mut cache, seed_hash, 7, unconfirmed.confirmed, 1); + store( + &mut cache, + seed_hash, + 7, + unconfirmed_request, + 0, + unconfirmed.confirmed, + 1, + ); + + request(&mut cache, seed_hash, 8, confirmed.confirmed, 2); + } + + #[test] + fn asset_lock_balance_cache_requeries_same_generation_after_loaded_signal_changes() { + let seed_hash = [0x2f; 32]; + let mut cache = AssetLockBalanceCache::default(); + + let first_request = request(&mut cache, seed_hash, 7, 1_000, 1); + store(&mut cache, seed_hash, 7, first_request, 900, 1_000, 1); + assert_eq!(cache.get(&seed_hash), Some(900)); + + let replacement_request = request(&mut cache, seed_hash, 7, 1_500, 2); + store( + &mut cache, + seed_hash, + 7, + replacement_request, + 1_400, + 1_500, + 2, + ); + assert_eq!(cache.get(&seed_hash), Some(1_400)); + assert!( + cache + .ensure_requested(seed_hash, 7, snapshot_inputs(1_500, 2), 2) + .is_none(), + "the replacement result must deduplicate its own generation and signal" + ); + } + + #[test] + fn asset_lock_balance_cache_rejects_superseded_reply_at_same_generation() { + let seed_hash = [0x31; 32]; + let mut cache = AssetLockBalanceCache::default(); + + let request_t1 = request(&mut cache, seed_hash, 7, 1_000, 1); + let request_t2 = request(&mut cache, seed_hash, 7, 1_500, 2); + assert!( + request_t2 > request_t1, + "every actual dispatch must advance the request ID" + ); + + store(&mut cache, seed_hash, 7, request_t1, 900, 1_000, 1); + assert_eq!( + cache.get_current(&seed_hash, &snapshot_inputs(1_500, 2)), + None, + "T1's stale reply must not be tagged as T2's current quote" + ); + cache.mark_loading_failed(&seed_hash, 7, request_t1); + assert!( + !cache.is_failed(&seed_hash), + "T1's stale failure must not mark T2 as failed" + ); + store(&mut cache, seed_hash, 7, request_t2, 1_400, 1_500, 2); + assert_eq!( + cache.get_current(&seed_hash, &snapshot_inputs(1_500, 2)), + Some(1_400) + ); + } + + #[test] + fn asset_lock_balance_cache_blocks_stale_validation_after_utxo_composition_change() { + let seed_hash = [0x30; 32]; + let mut cache = AssetLockBalanceCache::default(); + + let stale_request = request(&mut cache, seed_hash, 7, 1_000, 1); + store(&mut cache, seed_hash, 7, stale_request, 900, 1_000, 1); + assert_eq!(cache.get(&seed_hash), Some(900)); + + let current_request = request(&mut cache, seed_hash, 8, 1_000, 2); + assert_eq!( + cache.get(&seed_hash), + Some(900), + "stale-while-revalidate must keep the prior quote displayable" + ); + assert_eq!( + cache.get_current(&seed_hash, &snapshot_inputs(1_000, 2)), + None, + "validation must not use the stale higher quote for the new composition" + ); + + store(&mut cache, seed_hash, 8, current_request, 700, 1_000, 2); + assert_eq!( + cache.get_current(&seed_hash, &snapshot_inputs(1_000, 2)), + Some(700) + ); + } + + #[test] + fn asset_lock_balance_cache_validates_the_observed_not_dispatched_composition() { + let seed_hash = [0x32; 32]; + let mut cache = AssetLockBalanceCache::default(); + let dispatched = input_state(1, 1_000); + let observed = input_state(2, 900); + + let request_id = cache + .ensure_requested(seed_hash, 7, dispatched.clone(), 1) + .and_then(|task| match task { + BackendTask::WalletTask(WalletTask::GetAssetLockMaxAmount { + request_id, .. + }) => Some(request_id), + _ => None, + }) + .expect("asset-lock maximum request"); + cache.store(seed_hash, 7, request_id, 800, observed.clone(), false); + + assert_eq!( + cache.get_current(&seed_hash, &dispatched), + None, + "dispatch-time bookkeeping must not validate a quote measured against other inputs" + ); + assert_eq!(cache.get_current(&seed_hash, &observed), Some(800)); + } + + #[test] + fn asset_lock_balance_cache_redispatches_and_offers_retry_after_reply_deadline() { + let seed_hash = [0x33; 32]; + let mut cache = AssetLockBalanceCache::default(); + let inputs = input_state(3, 1_000); + let started_at = Instant::now(); + + let first = cache + .ensure_requested_at(seed_hash, 7, inputs.clone(), 1, started_at) + .expect("initial request"); + assert!( + cache + .ensure_requested_at( + seed_hash, + 7, + inputs.clone(), + 1, + started_at + Duration::from_secs(1), + ) + .is_none(), + "a live request must still deduplicate" + ); + + let after_deadline = + started_at + super::ASSET_LOCK_REQUEST_DEADLINE + Duration::from_nanos(1); + let replacement = cache + .ensure_requested_at(seed_hash, 8, inputs, 1, after_deadline) + .expect("expired request must be replaced"); + + assert_ne!( + asset_lock_request_id(first), + asset_lock_request_id(replacement), + "the replacement must have a fresh request ID" + ); + assert!( + cache.should_offer_retry(&seed_hash), + "the loading UI must expose Retry after a reply deadline expires" + ); + } + + /// Dispatch for `published` at generation 7 / revision 1 and reply with the + /// fail-closed empty marker, which can never match a non-empty composition. + fn mismatched_reply_round( + cache: &mut AssetLockBalanceCache, + seed_hash: [u8; 32], + published: &AssetLockInputState, + ) { + let request_id = cache + .ensure_requested(seed_hash, 7, published.clone(), 1) + .map(asset_lock_request_id) + .expect("mismatch round must dispatch"); + cache.store( + seed_hash, + 7, + request_id, + 0, + AssetLockInputState::default(), + true, + ); + } + + #[test] + fn asset_lock_balance_cache_stops_redispatching_after_persistent_composition_mismatch() { + let seed_hash = [0x38; 32]; + let mut cache = AssetLockBalanceCache::default(); + let published = input_state(6, 1_000); + + mismatched_reply_round(&mut cache, seed_hash, &published); + // The second round's own dispatch `expect` asserts the first mismatch + // still allows one automatic re-probe. + mismatched_reply_round(&mut cache, seed_hash, &published); + + assert!( + cache + .ensure_requested(seed_hash, 7, published.clone(), 1) + .is_none(), + "a second consecutive mismatched reply must stop the automatic re-dispatch loop" + ); + assert!( + cache + .ensure_requested(seed_hash, 9, published.clone(), 1) + .is_none(), + "generation-only churn must not re-arm a suppressed mismatch" + ); + assert!( + cache.is_failed(&seed_hash), + "a persistent mismatch must surface as a failed check, not eternal loading" + ); + assert!( + cache.should_offer_retry(&seed_hash), + "the suppressed state must expose an explicit Retry" + ); + assert!( + cache + .ensure_requested(seed_hash, 10, input_state(7, 900), 2) + .is_some(), + "a real composition change must re-arm the probe" + ); + } + + #[test] + fn asset_lock_balance_cache_retry_rearms_a_mismatch_suppressed_wallet() { + let seed_hash = [0x39; 32]; + let mut cache = AssetLockBalanceCache::default(); + let published = input_state(8, 1_000); + + mismatched_reply_round(&mut cache, seed_hash, &published); + mismatched_reply_round(&mut cache, seed_hash, &published); + assert!( + cache + .ensure_requested(seed_hash, 7, published.clone(), 1) + .is_none(), + "the mismatch loop must be suppressed before Retry" + ); + + cache.invalidate_one(&seed_hash); + assert!( + cache.ensure_requested(seed_hash, 7, published, 1).is_some(), + "Retry (invalidate_one) must re-arm a mismatch-suppressed wallet" + ); + } + + #[test] + fn invalidate_one_rearms_only_the_selected_wallet() { + let first_seed = [0x34; 32]; + let second_seed = [0x35; 32]; + let mut cache = AssetLockBalanceCache::default(); + let inputs = input_state(4, 1_000); + + assert!( + cache + .ensure_requested(first_seed, 1, inputs.clone(), 1) + .is_some() + ); + assert!( + cache + .ensure_requested(second_seed, 1, inputs.clone(), 1) + .is_some() + ); + cache.invalidate_one(&first_seed); + + assert!( + cache + .ensure_requested(first_seed, 1, inputs.clone(), 1) + .is_some(), + "the invalidated wallet must dispatch again" + ); + assert!( + cache.ensure_requested(second_seed, 1, inputs, 1).is_none(), + "other wallet state must remain deduplicated" + ); + } + + #[test] + fn invalidate_rearms_every_wallet() { + let first_seed = [0x36; 32]; + let second_seed = [0x37; 32]; + let mut cache = AssetLockBalanceCache::default(); + let inputs = input_state(5, 1_000); + + assert!( + cache + .ensure_requested(first_seed, 1, inputs.clone(), 1) + .is_some() + ); + assert!( + cache + .ensure_requested(second_seed, 1, inputs.clone(), 1) + .is_some() + ); + cache.invalidate(); + + assert!( + cache + .ensure_requested(first_seed, 1, inputs.clone(), 1) + .is_some() + ); + assert!(cache.ensure_requested(second_seed, 1, inputs, 1).is_some()); + } + + fn asset_lock_request_id(task: BackendTask) -> u64 { + match task { + BackendTask::WalletTask(WalletTask::GetAssetLockMaxAmount { request_id, .. }) => { + request_id + } + other => panic!("expected asset-lock maximum request, got {other:?}"), + } + } +} diff --git a/src/ui/state/mod.rs b/src/ui/state/mod.rs index 481ca4074..a0c210c21 100644 --- a/src/ui/state/mod.rs +++ b/src/ui/state/mod.rs @@ -6,6 +6,7 @@ //! `ui/components/`, which is reserved for renderable widget types. pub mod account_summary; +pub mod asset_lock_balance; pub mod avatar_cache; pub mod contacts_view; pub mod global_nav; @@ -14,5 +15,6 @@ pub mod legacy_recovery; pub mod masternodes_view; pub mod tracked_asset_lock_cache; +pub use asset_lock_balance::AssetLockBalanceCache; pub use avatar_cache::AvatarCache; pub use tracked_asset_lock_cache::TrackedAssetLockCache; diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index 7a43fcee5..5dbe94401 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -1,13 +1,16 @@ -use crate::app::AppAction; +use crate::app::{AppAction, BackendTasksExecutionMode}; use crate::backend_task::core::{CoreTask, PaymentRecipient, WalletPaymentRequest}; use crate::backend_task::error::TaskError; use crate::backend_task::identity::{IdentityTask, IdentityTopUpInfo, TopUpIdentityFundingMethod}; use crate::backend_task::wallet::WalletTask; -use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; +use crate::backend_task::{BackendTask, BackendTaskContext, BackendTaskSuccessResult}; use crate::context::AppContext; use crate::context::feature_gate::FeatureGate; use crate::model::address::{AddressKind, ValidatedAddress}; use crate::model::amount::{Amount, DASH_DECIMAL_PLACES}; +use crate::model::asset_lock::{ + AssetLockAmountError, asset_lock_user_max_amount, validate_asset_lock_amount, +}; use crate::model::fee_estimation::{ MAX_PLATFORM_INPUTS, PlatformFeeEstimator, allocate_platform_addresses, allocate_platform_addresses_with_fee, core_max_send_amount_duffs, core_max_send_reserve_duffs, @@ -29,8 +32,12 @@ use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; +use crate::ui::state::AssetLockBalanceCache; use crate::ui::theme::DashColors; -use crate::ui::{MessageType, RootScreenType, ScreenLike}; +use crate::ui::{ + MessageType, RootScreenType, ScreenLike, append_concurrent_backend_tasks, + can_append_concurrent_backend_tasks, +}; use dash_sdk::Error as SdkError; use dash_sdk::dashcore_rpc::dashcore::Address; use dash_sdk::dashcore_rpc::dashcore::address::NetworkUnchecked; @@ -315,6 +322,7 @@ pub struct WalletSendScreen { send_status: SendStatus, send_banner: Option, send_confirmation: Option, + asset_lock_balance: AssetLockBalanceCache, /// Preset flow this screen was opened for. `General` is the full send /// screen; the shielded presets lock source/destination for that flow. @@ -325,6 +333,29 @@ pub struct WalletSendScreen { wallet_open_attempted: bool, } +/// Merge the confirmation dialog's action into the frame action. The dialog +/// consumes the click and clears itself, so a confirmed send is one-shot: +/// it appends to a compatible task batch, and otherwise takes the pre-existing +/// `AppAction` `|=` precedence over a same-frame navigation action — it is +/// never silently dropped. +fn merge_confirmation_action(mut action: AppAction, confirmation_action: AppAction) -> AppAction { + if can_append_concurrent_backend_tasks(&action) { + match confirmation_action { + AppAction::BackendTask(task) => append_concurrent_backend_tasks(action, vec![task]), + AppAction::BackendTasks(tasks, BackendTasksExecutionMode::Concurrent) => { + append_concurrent_backend_tasks(action, tasks) + } + other => { + action |= other; + action + } + } + } else { + action |= confirmation_action; + action + } +} + impl WalletSendScreen { pub fn new(app_context: &Arc, wallet: Arc>) -> Self { let seed_hash = wallet.read().ok().map(|w| w.seed_hash()); @@ -351,6 +382,7 @@ impl WalletSendScreen { send_status: SendStatus::NotStarted, send_banner: None, send_confirmation: None, + asset_lock_balance: AssetLockBalanceCache::default(), flow: SendFlow::General, wallet_unlock_popup: WalletUnlockPopup::new(), wallet_open_attempted: false, @@ -440,6 +472,7 @@ impl WalletSendScreen { self.amount_input = None; self.invalidate_address_input(); self.send_confirmation = None; + self.asset_lock_balance.invalidate(); } fn reset_form(&mut self) { @@ -460,6 +493,7 @@ impl WalletSendScreen { self.fee_strategy = PlatformFeeStrategy::default(); self.send_status = SendStatus::NotStarted; self.send_confirmation = None; + self.asset_lock_balance.invalidate(); } fn mark_sending(&mut self) { @@ -606,14 +640,11 @@ impl WalletSendScreen { } } - /// Get the Core wallet's **spendable** balance from the display-only - /// `WalletBackend` snapshot (P4a). DISPLAY-ONLY — this number never feeds - /// coin selection itself, but it must mirror what coin selection can spend - /// so the amount checks here agree with the actual send. `spendable()` is - /// the upstream `CoinSelector`'s set (confirmed + unconfirmed); reading - /// `confirmed` alone would understate IS-locked funds that have not yet been - /// flagged locally (they sit in `unconfirmed`), making "Max" exceed this - /// check and the validations reject sends coin selection would accept. + /// Get the Core wallet's display-only balance for labels and ordinary sends. + /// + /// Asset-lock Max and validation use [`Self::asset_lock_max_amount`] + /// instead because those transactions require final inputs and can reject + /// outputs counted by this snapshot. fn get_core_balance(&self) -> u64 { self.selected_wallet .as_ref() @@ -626,6 +657,66 @@ impl WalletSendScreen { .unwrap_or(0) } + fn asset_lock_max_amount(&self, seed_hash: &WalletSeedHash) -> Result { + let (_, input_state, _) = self.app_context.asset_lock_probe_snapshot(seed_hash); + self.asset_lock_balance + .get_current(seed_hash, &input_state) + .ok_or_else(|| { + self.asset_lock_balance + .validation_unavailable_message(seed_hash) + .to_string() + }) + } + + fn request_asset_lock_max_amount(&mut self) -> Option { + if !matches!(self.selected_source, Some(SourceSelection::CoreWallet)) + || !matches!( + self.destination_kind(), + Some(AddressKind::Platform | AddressKind::Shielded | AddressKind::Identity) + ) + { + return None; + } + let seed_hash = self.selected_wallet_seed_hash?; + let (snapshot_generation, input_state, utxo_revision) = + self.app_context.asset_lock_probe_snapshot(&seed_hash); + self.asset_lock_balance.ensure_requested( + seed_hash, + snapshot_generation, + input_state, + utxo_revision, + ) + } + + fn render_asset_lock_balance_status(&mut self, ui: &mut Ui) { + let Some(seed_hash) = self.selected_wallet_seed_hash else { + return; + }; + if !matches!(self.selected_source, Some(SourceSelection::CoreWallet)) + || !matches!( + self.destination_kind(), + Some(AddressKind::Platform | AddressKind::Shielded | AddressKind::Identity) + ) + { + return; + } + + let failed = self.asset_lock_balance.is_failed(&seed_hash); + let loading = self.asset_lock_max_amount(&seed_hash).is_err(); + if failed { + ui.label("The available amount could not be checked."); + } else if loading { + ui.label("Checking the available amount…"); + } else if self.asset_lock_balance.should_offer_retry(&seed_hash) { + ui.label("The amount shown is safe but may be lower than your full available amount."); + } + if self.asset_lock_balance.should_offer_retry(&seed_hash) + && ui.button("Retry available amount check").clicked() + { + self.asset_lock_balance.invalidate_one(&seed_hash); + } + } + /// Get loaded identities for the current wallet, filtered by wallet seed hash. fn get_loaded_identities(&self) -> Vec { let Some(wallet_arc) = &self.selected_wallet else { @@ -899,21 +990,29 @@ impl WalletSendScreen { return Err("Amount must be greater than 0".to_string()); } - // Extract validated platform address let destination = self .validated_destination .as_ref() .and_then(|v| v.as_platform().copied()) .ok_or_else(|| "Invalid platform address".to_string())?; - - // Check balance; fees will be subtracted from amount - let required = amount_duffs; - let balance = self.get_core_balance(); - if required > balance { + let platform_fee_duffs = estimate_address_funding_fee_from_transition( + self.app_context.platform_version(), + &destination, + ) + .div_ceil(CREDITS_PER_DUFF); + let asset_lock_max = self.asset_lock_max_amount(&seed_hash)?; + if let Err(error) = + validate_asset_lock_amount(amount_duffs, platform_fee_duffs, asset_lock_max) + { + let maximum_amount_duffs = match error { + AssetLockAmountError::Overflow => asset_lock_max, + AssetLockAmountError::ExceedsMaximum { + maximum_amount_duffs, + } => maximum_amount_duffs, + }; return Err(format!( - "Insufficient balance. Need {} (including fee) but have {}", - format_duffs_as_dash(required), - format_duffs_as_dash(balance) + "You can transfer up to {} right now. Choose a smaller amount or wait for more funds.", + format_duffs_as_dash(maximum_amount_duffs) )); } @@ -1276,6 +1375,7 @@ impl WalletSendScreen { ui.add_space(10.0); // Amount + self.render_asset_lock_balance_status(ui); self.render_amount_input(ui); ui.add_space(10.0); @@ -1432,7 +1532,6 @@ impl WalletSendScreen { self.sync_flow_state(); self.render_flow_source(ui); - ui.add_space(10.0); ui.separator(); ui.add_space(10.0); @@ -1445,6 +1544,7 @@ impl WalletSendScreen { ui.add_space(10.0); } + self.render_asset_lock_balance_status(ui); self.render_amount_input(ui); ui.add_space(10.0); @@ -1572,12 +1672,23 @@ impl WalletSendScreen { return Err("Amount must be greater than 0".to_string()); } - let balance = self.get_core_balance(); - if amount_duffs > balance { + let (platform_fee_duffs, _) = self + .app_context + .fee_estimator() + .estimate_shield_from_core_fees_duffs(); + let asset_lock_max = self.asset_lock_max_amount(&seed_hash)?; + if let Err(error) = + validate_asset_lock_amount(amount_duffs, platform_fee_duffs, asset_lock_max) + { + let maximum_amount_duffs = match error { + AssetLockAmountError::Overflow => asset_lock_max, + AssetLockAmountError::ExceedsMaximum { + maximum_amount_duffs, + } => maximum_amount_duffs, + }; return Err(format!( - "Insufficient balance. Need {} but have {}", - format_duffs_as_dash(amount_duffs), - format_duffs_as_dash(balance) + "You can transfer up to {} right now. Choose a smaller amount or wait for more funds.", + format_duffs_as_dash(maximum_amount_duffs) )); } @@ -1590,7 +1701,7 @@ impl WalletSendScreen { } /// Top up an identity from Core wallet via asset lock (Core -> Identity). - fn send_core_to_identity(&mut self, _seed_hash: WalletSeedHash) -> Result { + fn send_core_to_identity(&mut self, seed_hash: WalletSeedHash) -> Result { let amount_duffs = self .amount .as_ref() @@ -1600,12 +1711,24 @@ impl WalletSendScreen { return Err("Amount must be greater than 0".to_string()); } - let balance = self.get_core_balance(); - if amount_duffs > balance { + let identity_fee_duffs = self + .app_context + .fee_estimator() + .estimate_identity_topup() + .div_ceil(CREDITS_PER_DUFF); + let asset_lock_max = self.asset_lock_max_amount(&seed_hash)?; + if let Err(error) = + validate_asset_lock_amount(amount_duffs, identity_fee_duffs, asset_lock_max) + { + let maximum_amount_duffs = match error { + AssetLockAmountError::Overflow => asset_lock_max, + AssetLockAmountError::ExceedsMaximum { + maximum_amount_duffs, + } => maximum_amount_duffs, + }; return Err(format!( - "Insufficient balance. Need {} but have {}", - format_duffs_as_dash(amount_duffs), - format_duffs_as_dash(balance) + "You can transfer up to {} right now. Choose a smaller amount or wait for more funds.", + format_duffs_as_dash(maximum_amount_duffs) )); } @@ -2366,19 +2489,27 @@ impl WalletSendScreen { // Get max amount and hint based on source selection let (max_amount_credits, max_hint) = match &self.selected_source { Some(SourceSelection::CoreWallet) => { + let dest_kind = self.destination_kind(); let mut max = self.selected_wallet.as_ref().and_then(|w| { - w.read().ok().map(|wallet| { - // Reserve against the spendable set (confirmed + - // unconfirmed), not `total` — `total` counts immature - // and locked funds coin selection can't spend, so a - // total-based Max over-shoots and the send fails. - self.app_context - .snapshot_balance(&wallet.seed_hash()) - .spendable() - * CREDITS_PER_DUFF // duffs to credits + w.read().ok().and_then(|wallet| { + let seed_hash = wallet.seed_hash(); + let max_duffs = if matches!( + dest_kind, + Some( + AddressKind::Platform + | AddressKind::Shielded + | AddressKind::Identity + ) + ) { + // Same current-composition accessor validation + // uses, so Max can never offer a stale ceiling. + self.asset_lock_max_amount(&seed_hash).ok() + } else { + Some(self.app_context.snapshot_balance(&seed_hash).spendable()) + }; + max_duffs.map(|amount| amount.saturating_mul(CREDITS_PER_DUFF)) }) }); - let dest_kind = self.destination_kind(); let hint = match dest_kind { Some(AddressKind::Platform) => { let destination = self @@ -2402,14 +2533,26 @@ impl WalletSendScreen { Some(AddressKind::Shielded) => { let (platform_fee_duffs, l1_tx_fee_duffs) = fee_estimator.estimate_shield_from_core_fees_duffs(); - let total_fee_credits = - (platform_fee_duffs + l1_tx_fee_duffs) * CREDITS_PER_DUFF; - max = max.map(|amount| amount.saturating_sub(total_fee_credits)); + let platform_fee_credits = + platform_fee_duffs.saturating_mul(CREDITS_PER_DUFF); + let total_fee_credits = platform_fee_duffs + .saturating_add(l1_tx_fee_duffs) + .saturating_mul(CREDITS_PER_DUFF); + max = max + .map(|amount| asset_lock_user_max_amount(amount, platform_fee_credits)); let fee = format_credits_as_dash(total_fee_credits); Some(format!( "Shielding fees of approximately {fee} are reserved from your balance." )) } + Some(AddressKind::Identity) => { + let estimated_fee = fee_estimator.estimate_identity_topup(); + max = max.map(|amount| amount.saturating_sub(estimated_fee)); + let fee = format_credits_as_dash(estimated_fee); + Some(format!( + "An identity top-up fee of approximately {fee} is reserved from your balance." + )) + } Some(AddressKind::Core) => { // Core-to-Core "Max": reserve the L1 network fee so the // send leaves enough to cover it. The fee scales with @@ -3198,6 +3341,36 @@ impl WalletSendScreen { self.amount = amount; } + #[cfg(feature = "testing")] + #[doc(hidden)] + /// Seeds the completed asset-lock maximum lookup for headless integration tests. + pub fn seed_asset_lock_max_amount_for_test( + &mut self, + seed_hash: WalletSeedHash, + amount_duffs: u64, + ) { + let (snapshot_generation, input_state, utxo_revision) = + self.app_context.asset_lock_probe_snapshot(&seed_hash); + let Some(BackendTask::WalletTask(WalletTask::GetAssetLockMaxAmount { request_id, .. })) = + self.asset_lock_balance.ensure_requested( + seed_hash, + snapshot_generation, + input_state.clone(), + utxo_revision, + ) + else { + return; + }; + self.asset_lock_balance.store( + seed_hash, + snapshot_generation, + request_id, + amount_duffs, + input_state, + false, + ); + } + #[cfg(feature = "testing")] #[doc(hidden)] /// Runs the production simple-send validation and dispatch selection. @@ -4175,7 +4348,14 @@ impl ScreenLike for WalletSendScreen { inner_action }); - action |= self.render_send_confirmation(ui); + let confirmation_action = self.render_send_confirmation(ui); + action = merge_confirmation_action(action, confirmation_action); + + if can_append_concurrent_backend_tasks(&action) + && let Some(task) = self.request_asset_lock_max_amount() + { + action = append_concurrent_backend_tasks(action, vec![task]); + } // Show wallet unlock popup if open if self.wallet_unlock_popup.is_open() @@ -4213,6 +4393,25 @@ impl ScreenLike for WalletSendScreen { &mut self, backend_task_success_result: crate::backend_task::BackendTaskSuccessResult, ) { + if let crate::backend_task::BackendTaskSuccessResult::AssetLockMaxAmount { + seed_hash, + snapshot_generation, + request_id, + amount_duffs, + observed_inputs, + is_partial, + } = &backend_task_success_result + { + self.asset_lock_balance.store( + *seed_hash, + *snapshot_generation, + *request_id, + *amount_duffs, + observed_inputs.clone(), + *is_partial, + ); + return; + } self.send_banner.take_and_clear(); if Self::task_result_updates_address_input(&backend_task_success_result) { self.address_input_snapshot_signature = None; @@ -4332,10 +4531,32 @@ impl ScreenLike for WalletSendScreen { fn refresh_on_arrival(&mut self) { self.address_input_snapshot_signature = None; + self.asset_lock_balance.invalidate(); } fn refresh(&mut self) { self.address_input_snapshot_signature = None; + self.asset_lock_balance.invalidate(); + } + + fn display_backend_task_error(&mut self, context: &BackendTaskContext, _error: &TaskError) { + if let Some((seed_hash, snapshot_generation, request_id)) = + context.asset_lock_max_amount_request() + { + self.asset_lock_balance.mark_loading_failed( + &seed_hash, + snapshot_generation, + request_id, + ); + } + } + + fn should_suppress_backend_task_error( + &self, + context: &BackendTaskContext, + _error: &TaskError, + ) -> bool { + context.asset_lock_max_amount_request().is_some() } } @@ -4345,14 +4566,23 @@ mod tests { use crate::context::connection_status::ConnectionStatus; use crate::database::test_helpers::create_database_at_path; use crate::utils::tasks::TaskManager; + use crate::wallet_backend::AssetLockInputState; use dash_sdk::dashcore_rpc::dashcore::secp256k1::{Secp256k1, SecretKey}; use dash_sdk::dashcore_rpc::dashcore::{Network, PrivateKey, PublicKey}; + use dash_sdk::dpp::dashcore::{OutPoint, Txid, hashes::Hash}; use dash_sdk::dpp::key_wallet::bip32::{ChildNumber, DerivationPath}; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; use std::cell::RefCell; use std::rc::Rc; + fn different_asset_lock_inputs(seed_byte: u8) -> AssetLockInputState { + AssetLockInputState::from_inputs([( + OutPoint::new(Txid::from_byte_array([seed_byte; 32]), 0), + 1, + )]) + } + fn testnet_core_address(key_byte: u8) -> Address { let secp = Secp256k1::new(); let secret_key = SecretKey::from_slice(&[key_byte; 32]).expect("valid secret key"); @@ -4413,6 +4643,157 @@ mod tests { ) } + fn asset_lock_request_id(task: Option) -> u64 { + match task { + Some(BackendTask::WalletTask(WalletTask::GetAssetLockMaxAmount { + request_id, .. + })) => request_id, + other => panic!("expected asset-lock maximum request, got {other:?}"), + } + } + + #[test] + fn same_frame_probe_and_confirmation_dispatch_are_both_preserved() { + let seed_hash = [0x43; 32]; + let confirmation = + BackendTask::WalletTask(WalletTask::GenerateReceiveAddress { seed_hash }); + let probe = BackendTask::WalletTask(WalletTask::GetAssetLockMaxAmount { + seed_hash, + snapshot_generation: 9, + request_id: 17, + }); + + let action = crate::ui::append_concurrent_backend_tasks( + AppAction::BackendTask(confirmation), + vec![probe], + ); + let AppAction::BackendTasks(tasks, BackendTasksExecutionMode::Concurrent) = action else { + panic!("same-frame tasks must be dispatched as one concurrent batch"); + }; + assert_eq!(tasks.len(), 2); + assert!(tasks.iter().any(|task| matches!( + task, + BackendTask::WalletTask(WalletTask::GetAssetLockMaxAmount { request_id: 17, .. }) + ))); + assert!(tasks.iter().any(|task| matches!( + task, + BackendTask::WalletTask(WalletTask::GenerateReceiveAddress { .. }) + ))); + assert!( + !crate::ui::can_append_concurrent_backend_tasks(&AppAction::PopScreen), + "navigation must keep priority instead of being overwritten by a probe" + ); + } + + #[test] + fn confirmed_send_action_survives_same_frame_navigation() { + let seed_hash = [0x44; 32]; + let confirmation = || { + AppAction::BackendTask(BackendTask::WalletTask( + WalletTask::GenerateReceiveAddress { seed_hash }, + )) + }; + + let merged = merge_confirmation_action(AppAction::PopScreen, confirmation()); + assert!( + matches!( + merged, + AppAction::BackendTask(BackendTask::WalletTask( + WalletTask::GenerateReceiveAddress { .. } + )) + ), + "a confirmed send must outrank a same-frame navigation action, never be dropped" + ); + + let sequential = AppAction::BackendTasks( + vec![BackendTask::WalletTask( + WalletTask::GenerateReceiveAddress { + seed_hash: [0x45; 32], + }, + )], + BackendTasksExecutionMode::Sequential, + ); + let merged = merge_confirmation_action(sequential, confirmation()); + assert!( + matches!( + merged, + AppAction::BackendTask(BackendTask::WalletTask( + WalletTask::GenerateReceiveAddress { seed_hash: sh } + )) if sh == seed_hash + ), + "a confirmed send must take the pre-existing |= precedence over a sequential batch" + ); + + assert!( + matches!( + merge_confirmation_action(AppAction::PopScreen, AppAction::None), + AppAction::PopScreen + ), + "without a confirmation the navigation action must pass through unchanged" + ); + + let merged = merge_confirmation_action(AppAction::None, confirmation()); + let AppAction::BackendTasks(tasks, BackendTasksExecutionMode::Concurrent) = merged else { + panic!("an appendable frame action must batch the confirmation concurrently"); + }; + assert_eq!(tasks.len(), 1); + } + + #[test] + fn max_button_offers_nothing_while_the_builder_quote_is_revalidating() { + const STALE_QUOTE_DUFFS: u64 = 900_000; + + let (mut screen, _temp_dir) = send_screen(); + let seed_hash = screen + .selected_wallet_seed_hash + .expect("selected wallet seed hash"); + let (snapshot_generation, current_inputs, utxo_revision) = + screen.app_context.asset_lock_probe_snapshot(&seed_hash); + let request_id = asset_lock_request_id(screen.asset_lock_balance.ensure_requested( + seed_hash, + snapshot_generation, + current_inputs.clone(), + utxo_revision, + )); + // The probe's reply observed a composition that no longer matches the + // wallet — the exact revalidation window behind issue #929. + screen.asset_lock_balance.store( + seed_hash, + snapshot_generation, + request_id, + STALE_QUOTE_DUFFS, + different_asset_lock_inputs(0x77), + false, + ); + assert_eq!( + screen.asset_lock_balance.get(&seed_hash), + Some(STALE_QUOTE_DUFFS), + "sanity: the stale quote must stay displayable" + ); + assert_eq!( + screen + .asset_lock_balance + .get_current(&seed_hash, ¤t_inputs), + None, + "sanity: validation must reject the stale quote" + ); + screen.selected_source = Some(SourceSelection::CoreWallet); + screen.validated_destination = Some(ValidatedAddress::Shielded(String::new())); + + let mut harness = Harness::builder().build_ui_state( + |ui, screen: &mut WalletSendScreen| screen.render_amount_input(ui), + screen, + ); + harness.run(); + harness.get_by_label("Max").click_accesskit(); + harness.step(); + + assert!( + harness.state().amount.is_none(), + "Max must never offer an amount that same-composition validation would refuse" + ); + } + fn click_in_one_frame(harness: &mut Harness<'_, WalletSendScreen>, label: &str) { let pos = harness.get_by_label(label).rect().center(); harness.input_mut().events.extend([ @@ -4433,6 +4814,241 @@ mod tests { harness.step(); } + #[test] + fn core_asset_lock_max_and_validation_use_builder_quote() { + const BUILDER_MAX_DUFFS: u64 = 10_000_000; + + let (mut screen, _temp_dir) = send_screen(); + let seed_hash = screen + .selected_wallet_seed_hash + .expect("selected wallet seed hash"); + let (platform_fee_duffs, _) = screen + .app_context + .fee_estimator() + .estimate_shield_from_core_fees_duffs(); + assert!(BUILDER_MAX_DUFFS > platform_fee_duffs); + + let (snapshot_generation, final_funds_duffs, utxo_revision) = + screen.app_context.asset_lock_probe_snapshot(&seed_hash); + let request_id = asset_lock_request_id(screen.asset_lock_balance.ensure_requested( + seed_hash, + snapshot_generation, + final_funds_duffs.clone(), + utxo_revision, + )); + screen.asset_lock_balance.store( + seed_hash, + snapshot_generation, + request_id, + BUILDER_MAX_DUFFS, + final_funds_duffs, + false, + ); + screen.selected_source = Some(SourceSelection::CoreWallet); + screen.validated_destination = Some(ValidatedAddress::Shielded(String::new())); + + let mut harness = Harness::builder().build_ui_state( + |ui, screen: &mut WalletSendScreen| screen.render_amount_input(ui), + screen, + ); + harness.run(); + harness.get_by_label("Max").click_accesskit(); + harness.step(); + + let max_amount_duffs = harness + .state() + .amount + .as_ref() + .expect("Max sets the amount") + .dash_to_duffs() + .expect("DASH amount"); + assert_eq!( + max_amount_duffs, + BUILDER_MAX_DUFFS - platform_fee_duffs, + "Max reserves the platform fee from the real builder ceiling" + ); + assert!( + harness.state_mut().send_core_to_shielded(seed_hash).is_ok(), + "the same ceiling must accept the Max amount" + ); + + harness.state_mut().amount = Some(Amount::dash_from_duffs(max_amount_duffs + 1)); + let error = harness + .state_mut() + .send_core_to_shielded(seed_hash) + .expect_err("one duff above Max must be rejected before dispatch"); + assert!( + error.starts_with("You can transfer up to "), + "validation should report the builder-derived ceiling: {error}" + ); + + harness.state_mut().validated_destination = Some(ValidatedAddress::Identity { + id: dash_sdk::dpp::prelude::Identifier::new([0x29; 32]), + dpns_name: None, + }); + harness.step(); + harness.get_by_label("Max").click_accesskit(); + harness.step(); + + let identity_max_duffs = harness + .state() + .amount + .as_ref() + .expect("Identity Max sets the amount") + .dash_to_duffs() + .expect("DASH amount"); + let identity_fee_credits = harness + .state() + .app_context + .fee_estimator() + .estimate_identity_topup(); + let identity_fee_duffs = identity_fee_credits.div_ceil(CREDITS_PER_DUFF); + assert_eq!( + identity_max_duffs, + BUILDER_MAX_DUFFS.saturating_sub(identity_fee_duffs), + "Identity Max must reserve the same top-up fee as the identity top-up screen" + ); + let downstream_error = harness + .state_mut() + .send_core_to_identity(seed_hash) + .expect_err("the offline test cannot resolve the destination identity"); + assert!( + downstream_error.starts_with("Could not look up identity:") + || downstream_error.starts_with("No identity found with this ID."), + "builder-derived Max must pass amount validation and reach identity lookup: \ + {downstream_error}" + ); + + harness.state_mut().amount = Some(Amount::dash_from_duffs(identity_max_duffs + 1)); + let error = harness + .state_mut() + .send_core_to_identity(seed_hash) + .expect_err("one duff above Identity Max must be rejected before dispatch"); + assert!( + error.starts_with("You can transfer up to "), + "validation should report the builder-derived identity ceiling: {error}" + ); + + let platform_destination = + PlatformAddress::try_from(testnet_core_address(4)).expect("platform destination"); + harness.state_mut().validated_destination = Some(ValidatedAddress::Platform { + address: platform_destination, + bech32m: platform_destination.to_bech32m_string(Network::Testnet), + }); + harness.step(); + harness.get_by_label("Max").click_accesskit(); + harness.step(); + + let platform_max_duffs = harness + .state() + .amount + .as_ref() + .expect("Platform Max sets the amount") + .dash_to_duffs() + .expect("DASH amount"); + let platform_fee_credits = estimate_address_funding_fee_from_transition( + harness.state().app_context.platform_version(), + &platform_destination, + ); + assert_eq!( + platform_max_duffs, + BUILDER_MAX_DUFFS + .saturating_mul(CREDITS_PER_DUFF) + .saturating_sub(platform_fee_credits) + / CREDITS_PER_DUFF, + "Platform Max must start from the builder ceiling before reserving its fee" + ); + assert!( + harness.state_mut().send_core_to_platform(seed_hash).is_ok(), + "the builder-derived Platform Max must pass pre-send validation" + ); + + harness.state_mut().amount = Some(Amount::dash_from_duffs(platform_max_duffs + 1)); + let error = harness + .state_mut() + .send_core_to_platform(seed_hash) + .expect_err("one duff above Platform Max must be rejected before dispatch"); + assert!( + error.starts_with("You can transfer up to "), + "Platform validation should report the builder-derived ceiling: {error}" + ); + } + + #[test] + fn core_asset_lock_validation_rejects_quote_for_stale_utxo_composition() { + const STALE_MAX_DUFFS: u64 = 10_000_000; + + let (mut screen, _temp_dir) = send_screen(); + let seed_hash = screen + .selected_wallet_seed_hash + .expect("selected wallet seed hash"); + let (_, current_final_funds, current_revision) = + screen.app_context.asset_lock_probe_snapshot(&seed_hash); + let stale_inputs = different_asset_lock_inputs(0x39); + + let request_id = asset_lock_request_id(screen.asset_lock_balance.ensure_requested( + seed_hash, + 7, + current_final_funds, + current_revision, + )); + screen.asset_lock_balance.store( + seed_hash, + 7, + request_id, + STALE_MAX_DUFFS, + stale_inputs, + false, + ); + screen.selected_source = Some(SourceSelection::CoreWallet); + screen.validated_destination = Some(ValidatedAddress::Shielded(String::new())); + screen.amount = Some(Amount::dash_from_duffs(1)); + + let error = screen + .send_core_to_shielded(seed_hash) + .expect_err("stale composition quote must not authorize a send"); + assert!( + error.contains("still being checked"), + "validation must wait for the current composition quote: {error}" + ); + } + + #[test] + fn core_asset_lock_dispatch_distinguishes_failed_probe_from_loading() { + let (mut screen, _temp_dir) = send_screen(); + let seed_hash = screen + .selected_wallet_seed_hash + .expect("selected wallet seed hash"); + let (snapshot_generation, final_funds_duffs, utxo_revision) = + screen.app_context.asset_lock_probe_snapshot(&seed_hash); + + let request_id = asset_lock_request_id(screen.asset_lock_balance.ensure_requested( + seed_hash, + snapshot_generation, + final_funds_duffs, + utxo_revision, + )); + let loading_message = screen + .asset_lock_max_amount(&seed_hash) + .expect_err("an in-flight probe must block dispatch"); + screen + .asset_lock_balance + .mark_loading_failed(&seed_hash, snapshot_generation, request_id); + let failed_message = screen + .asset_lock_max_amount(&seed_hash) + .expect_err("a failed probe must block dispatch"); + + assert!(loading_message.contains("still being checked")); + assert!( + failed_message.starts_with("The available amount could not be checked."), + "failed dispatch must direct the user to the retryable state: {failed_message}" + ); + assert_ne!( + failed_message, loading_message, + "a permanently failed probe must not be described as still loading" + ); + } + #[test] fn simple_send_click_opens_confirmation_without_dispatching() { let (mut screen, _temp_dir) = send_screen(); diff --git a/src/wallet_backend/mod.rs b/src/wallet_backend/mod.rs index 52344bf45..6405eca5c 100644 --- a/src/wallet_backend/mod.rs +++ b/src/wallet_backend/mod.rs @@ -98,9 +98,12 @@ pub use event_bridge::EventBridge; pub(crate) use kv::network_prefix; pub use kv::{DetKv, DetScope, KvAdapterError, SCHEMA_VERSION as KV_SCHEMA_VERSION}; pub use loader::LoadedWallets; +pub use payments::AssetLockMaxAmountQuote; pub use single_key::SingleKeyView; use snapshot::SnapshotStore; -pub use snapshot::{DetUtxo, DetWalletBalance, TransactionHistoryStatus, WalletSnapshot}; +pub use snapshot::{ + AssetLockInputState, DetUtxo, DetWalletBalance, TransactionHistoryStatus, WalletSnapshot, +}; use token_balance::TokenBalanceStore; pub use token_balance::UpstreamTokenBalances; pub use wallet_meta::WalletMetaView; @@ -2398,6 +2401,19 @@ impl WalletBackend { self.inner.snapshots.snapshot(seed_hash).balance } + /// Generation of the latest event-pushed wallet snapshot. + pub fn wallet_snapshot_generation(&self, seed_hash: &WalletSeedHash) -> u64 { + self.inner.snapshots.snapshot(seed_hash).generation + } + + /// Snapshot generation, exact builder-input composition, and its revision. + pub fn asset_lock_probe_snapshot( + &self, + seed_hash: &WalletSeedHash, + ) -> (u64, AssetLockInputState, u64) { + self.inner.snapshots.asset_lock_probe_snapshot(seed_hash) + } + /// Full transaction history for the wallet (event-sourced). pub fn transaction_history( &self, diff --git a/src/wallet_backend/payments.rs b/src/wallet_backend/payments.rs index 7161cfef4..e0d4abb44 100644 --- a/src/wallet_backend/payments.rs +++ b/src/wallet_backend/payments.rs @@ -10,11 +10,612 @@ use crate::backend_task::error::TaskError; use crate::model::wallet::WalletSeedHash; +use dash_sdk::dpp::dashcore::blockdata::constants::MAX_MONEY; +use dash_sdk::dpp::dashcore::blockdata::transaction::special_transaction::TransactionPayload; +use dash_sdk::dpp::dashcore::blockdata::transaction::special_transaction::asset_lock::AssetLockPayload; +use dash_sdk::dpp::dashcore::{ScriptBuf, TxOut}; +use dash_sdk::dpp::key_wallet::account::Account; +use dash_sdk::dpp::key_wallet::managed_account::ManagedCoreFundsAccount; +use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::coin_selection::{ + SelectionError, 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 std::sync::Arc; +use std::time::{Duration, Instant}; -use super::{DEFAULT_BIP44_ACCOUNT, DetSigner, SecretPlaintext, WalletBackend}; +use super::snapshot::asset_lock_final_input_state; +use super::{ + AssetLockInputState, DEFAULT_BIP44_ACCOUNT, DetSigner, SecretPlaintext, WalletBackend, +}; + +// `AssetLockManager` passes its private `DEFAULT_FEE_PER_KB` explicitly to key-wallet, so DET +// cannot reuse the real path's source. +// TODO(upstream): export that default or expose an asset-lock ceiling quote primitive. +const ASSET_LOCK_FEE_PER_KB: u64 = 1_000; +/// Budget for the probe's builder work — composition observation plus quote +/// search — measured from when the blocking task starts running, so total +/// exclusive wallet access is bounded by this plus setup overhead. +const ASSET_LOCK_PROBE_LOCK_HOLD_DEADLINE: Duration = Duration::from_secs(5); +const MAX_DRAIN_SEARCH_DOUBLINGS: u32 = 40; +const P2PKH_CREDIT_OUTPUT_SCRIPT_LEN: usize = 25; +const P2PKH_INPUT_SIZE: usize = 148; + +enum AssetLockDryRun { + Builds, + Rejected { available: u64 }, + TooManyInputs { max: usize }, +} + +enum AssetLockDrainSeed { + Ceiling(u64), + Unavailable, + TooManyInputs { max: usize }, +} + +/// Conservative builder quote and the exact input composition it observed. +#[derive(Debug, Clone)] +pub struct AssetLockMaxAmountQuote { + /// Largest amount proven buildable before the search deadline. + pub amount_duffs: u64, + /// Final, unreserved inputs visible to the builder under the wallet lock. + pub observed_inputs: AssetLockInputState, + /// Whether the deadline stopped the search before the exact maximum was found. + pub is_partial: bool, +} + +pub(super) struct ProbeDeadline { + expires_at: Option, + timed_out: std::cell::Cell, +} + +impl ProbeDeadline { + pub(super) fn after(duration: Duration) -> Self { + Self { + expires_at: Instant::now().checked_add(duration), + timed_out: std::cell::Cell::new(false), + } + } + + pub(super) fn unbounded() -> Self { + Self { + expires_at: None, + timed_out: std::cell::Cell::new(false), + } + } + + pub(super) fn expired(&self) -> bool { + let expired = self + .expires_at + .is_some_and(|expires_at| Instant::now() >= expires_at); + if expired { + self.timed_out.set(true); + } + expired + } + + fn timed_out(&self) -> bool { + self.timed_out.get() + } +} + +fn asset_lock_builder_height( + info: &dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::ManagedWalletInfo, +) -> u32 { + use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + + info.last_processed_height() +} + +fn dry_run_asset_lock_amount( + managed_account: &ManagedCoreFundsAccount, + account: &Account, + current_height: u32, + amount_duffs: u64, +) -> Result { + dry_run_asset_lock_amount_with_strategy( + managed_account, + account, + current_height, + amount_duffs, + None, + ) +} + +// INTENTIONAL(upstream-bnb-dos): default `BranchAndBound` is algorithmically +// bounded by rust-dashcore#919's suffix-sum feasibility prune and node budget, +// which closes rust-dashcore#918. The aggregate quote still has a deadline +// because it performs many individually bounded selections under a write lock. +// Confirmed present: `platform`'s `key-wallet` dep tracks rust-dashcore's +// `dash-evo-tool` integration branch (rev 34f0921e, which merges #919's +// source branch directly), and this crate's own `Cargo.toml` pins `platform` +// to rev a18bd1586858ef680124e150caad6a7dc21d0b64 (feat/platform-wallet- +// storage-rehydration tip) or later, which resolves to that same key-wallet +// rev or newer. If either pin ever moves backward, re-verify this holds. +fn dry_run_asset_lock_amount_with_strategy( + managed_account: &ManagedCoreFundsAccount, + account: &Account, + current_height: u32, + amount_duffs: u64, + selection_strategy: Option, +) -> Result { + let mut dry_run_account = managed_account.clone(); + let mut builder = TransactionBuilder::new() + .set_fee_rate(FeeRate::new(ASSET_LOCK_FEE_PER_KB)) + .set_current_height(current_height) + .set_special_payload(TransactionPayload::AssetLockPayloadType( + AssetLockPayload::new(vec![TxOut { + value: amount_duffs, + script_pubkey: ScriptBuf::from_bytes(vec![0; P2PKH_CREDIT_OUTPUT_SCRIPT_LEN]), + }]), + )) + .set_funding(&mut dry_run_account, account); + if let Some(strategy) = selection_strategy { + builder = builder.set_selection_strategy(strategy); + } + let result = builder.require_final_inputs().build_unsigned(); + + match result { + Ok((transaction, _)) => { + // `ManagedCoreFundsAccount::clone` shares the live `ReservationSet` + // (`Arc>`; source of truth: key-wallet's + // `managed_account/reservation.rs` doc comment). Every successful probe + // reserves real wallet outpoints, so this call MUST run on every success + // path; deleting it silently strands real UTXOs for the 24-block TTL. + dry_run_account.release_reservation(&transaction); + Ok(AssetLockDryRun::Builds) + } + Err(BuilderError::CoinSelection(SelectionError::NoUtxosAvailable)) => { + Ok(AssetLockDryRun::Rejected { available: 0 }) + } + Err(BuilderError::CoinSelection(SelectionError::InsufficientFunds { + available, .. + })) + | Err(BuilderError::InsufficientFunds { available, .. }) => { + Ok(AssetLockDryRun::Rejected { available }) + } + Err(BuilderError::TooManyInputs { max, .. }) => Ok(AssetLockDryRun::TooManyInputs { max }), + Err(source) => Err(source), + } +} + +fn asset_lock_drain_ceiling( + managed_account: &ManagedCoreFundsAccount, + account: &Account, + current_height: u32, + deadline: &ProbeDeadline, +) -> Result { + if deadline.expired() { + return Ok(AssetLockDrainSeed::Unavailable); + } + let mut dry_run_account = managed_account.clone(); + let result = TransactionBuilder::new() + .set_fee_rate(FeeRate::new(ASSET_LOCK_FEE_PER_KB)) + .set_current_height(current_height) + .set_selection_strategy(SelectionStrategy::All) + .set_special_payload(TransactionPayload::AssetLockPayloadType( + AssetLockPayload::new(vec![TxOut { + value: 1, + // Credit-output script length affects the serialized payload fee. + script_pubkey: ScriptBuf::from_bytes(vec![0; P2PKH_CREDIT_OUTPUT_SCRIPT_LEN]), + }]), + )) + .set_funding(&mut dry_run_account, account) + .require_final_inputs() + .build_unsigned(); + + match result { + Ok((transaction, _)) => { + // `ManagedCoreFundsAccount::clone` shares the live `ReservationSet` + // (`Arc>`; source of truth: key-wallet's + // `managed_account/reservation.rs` doc comment). Every successful probe + // reserves real wallet outpoints, so this call MUST run on every success + // path; deleting it silently strands real UTXOs for the 24-block TTL. + dry_run_account.release_reservation(&transaction); + transaction + .output + .first() + .map(|output| AssetLockDrainSeed::Ceiling(output.value)) + .ok_or_else(|| { + BuilderError::InvalidData("asset-lock drain produced no credit output".into()) + }) + } + Err(BuilderError::CoinSelection(SelectionError::NoUtxosAvailable)) + | Err(BuilderError::CoinSelection(SelectionError::InsufficientFunds { .. })) + | Err(BuilderError::InsufficientFunds { .. }) => Ok(AssetLockDrainSeed::Unavailable), + Err(BuilderError::TooManyInputs { max, .. }) => { + Ok(AssetLockDrainSeed::TooManyInputs { max }) + } + Err(source) => Err(source), + } +} + +fn asset_lock_input_cap_upper_bound( + managed_account: &ManagedCoreFundsAccount, + current_height: u32, + max_inputs: usize, +) -> u64 { + let mut values: Vec = managed_account + .utxos + .values() + .filter(|utxo| { + (utxo.is_confirmed || utxo.is_instantlocked) && utxo.is_spendable(current_height) + }) + .map(|utxo| utxo.value()) + .collect(); + values.sort_unstable_by(|left, right| right.cmp(left)); + values + .into_iter() + .take(max_inputs) + .fold(0_u64, u64::saturating_add) +} + +fn largest_first_asset_lock_max_below( + managed_account: &ManagedCoreFundsAccount, + account: &Account, + current_height: u32, + upper_bound: u64, + deadline: &ProbeDeadline, +) -> Result { + let mut low = 0; + let mut high = upper_bound; + while low < high { + if deadline.expired() { + return Ok(low); + } + let candidate = low + (high - low).div_ceil(2); + match dry_run_asset_lock_amount_with_strategy( + managed_account, + account, + current_height, + candidate, + Some(SelectionStrategy::LargestFirst), + )? { + AssetLockDryRun::Builds => low = candidate, + AssetLockDryRun::Rejected { .. } | AssetLockDryRun::TooManyInputs { .. } => { + high = candidate - 1 + } + } + } + Ok(low) +} + +fn default_strategy_max_from_seed( + managed_account: &ManagedCoreFundsAccount, + account: &Account, + current_height: u32, + seed: u64, + upper_bound: u64, + deadline: &ProbeDeadline, +) -> Result { + if deadline.expired() { + return Ok(0); + } + if !matches!( + dry_run_asset_lock_amount(managed_account, account, current_height, seed)?, + AssetLockDryRun::Builds + ) { + return asset_lock_max_below_upper_bound( + managed_account, + account, + current_height, + seed, + deadline, + ); + } + + let mut low = seed; + let mut step = 1_u64; + while low < upper_bound { + if deadline.expired() { + return Ok(low); + } + let candidate = seed.saturating_add(step).min(upper_bound); + match dry_run_asset_lock_amount(managed_account, account, current_height, candidate)? { + AssetLockDryRun::Builds if candidate == upper_bound => return Ok(candidate), + AssetLockDryRun::Builds => { + low = candidate; + step = step.saturating_mul(2); + } + AssetLockDryRun::Rejected { .. } | AssetLockDryRun::TooManyInputs { .. } => { + let mut high = candidate - 1; + while low < high { + if deadline.expired() { + return Ok(low); + } + let midpoint = low + (high - low).div_ceil(2); + match dry_run_asset_lock_amount( + managed_account, + account, + current_height, + midpoint, + )? { + AssetLockDryRun::Builds => low = midpoint, + AssetLockDryRun::Rejected { .. } + | AssetLockDryRun::TooManyInputs { .. } => high = midpoint - 1, + } + } + return Ok(low); + } + } + } + Ok(low) +} + +fn asset_lock_max_with_input_cap( + managed_account: &ManagedCoreFundsAccount, + account: &Account, + current_height: u32, + max_inputs: usize, + deadline: &ProbeDeadline, +) -> Result { + if deadline.expired() { + return Ok(0); + } + let upper_bound = asset_lock_input_cap_upper_bound(managed_account, current_height, max_inputs); + let largest_first_seed = largest_first_asset_lock_max_below( + managed_account, + account, + current_height, + upper_bound, + deadline, + )?; + // Branch-and-bound's exact-match sizing looks one P2PKH input ahead; this + // is only a starting point, and default-strategy probes remain authoritative. + let seed = largest_first_seed + .saturating_sub(FeeRate::new(ASSET_LOCK_FEE_PER_KB).calculate_fee(P2PKH_INPUT_SIZE)); + default_strategy_max_from_seed( + managed_account, + account, + current_height, + seed, + upper_bound, + deadline, + ) +} + +fn asset_lock_max_below_upper_bound( + managed_account: &ManagedCoreFundsAccount, + account: &Account, + current_height: u32, + upper_bound: u64, + deadline: &ProbeDeadline, +) -> Result { + let mut step = 1_u64; + let mut high = upper_bound; + for _ in 0..MAX_DRAIN_SEARCH_DOUBLINGS { + if deadline.expired() { + return Ok(0); + } + let candidate = upper_bound.saturating_sub(step); + match dry_run_asset_lock_amount(managed_account, account, current_height, candidate)? { + AssetLockDryRun::Builds => { + let mut low = candidate; + while low < high { + if deadline.expired() { + return Ok(low); + } + let midpoint = low + (high - low).div_ceil(2); + match dry_run_asset_lock_amount( + managed_account, + account, + current_height, + midpoint, + )? { + AssetLockDryRun::Builds => low = midpoint, + AssetLockDryRun::Rejected { .. } + | AssetLockDryRun::TooManyInputs { .. } => high = midpoint - 1, + } + } + return Ok(low); + } + AssetLockDryRun::Rejected { .. } | AssetLockDryRun::TooManyInputs { .. } + if candidate == 0 => + { + return Ok(0); + } + AssetLockDryRun::Rejected { .. } | AssetLockDryRun::TooManyInputs { .. } => { + high = candidate - 1; + step = step.saturating_mul(2); + } + } + } + + full_range_asset_lock_max_amount(managed_account, account, current_height, None, deadline) +} + +fn full_range_asset_lock_max_amount( + managed_account: &ManagedCoreFundsAccount, + account: &Account, + current_height: u32, + input_cap: Option, + deadline: &ProbeDeadline, +) -> Result { + if let Some(max_inputs) = input_cap { + return asset_lock_max_with_input_cap( + managed_account, + account, + current_height, + max_inputs, + deadline, + ); + } + + if deadline.expired() { + return Ok(0); + } + + let mut high = match dry_run_asset_lock_amount( + managed_account, + account, + current_height, + MAX_MONEY.saturating_add(1), + )? { + AssetLockDryRun::Rejected { available } => available, + AssetLockDryRun::Builds => MAX_MONEY, + AssetLockDryRun::TooManyInputs { max } => { + return asset_lock_max_with_input_cap( + managed_account, + account, + current_height, + max, + deadline, + ); + } + }; + let mut low = 0; + + while low < high { + if deadline.expired() { + return Ok(low); + } + let candidate = low + (high - low).div_ceil(2); + match dry_run_asset_lock_amount(managed_account, account, current_height, candidate)? { + AssetLockDryRun::Builds => low = candidate, + AssetLockDryRun::Rejected { .. } | AssetLockDryRun::TooManyInputs { .. } => { + high = candidate - 1 + } + } + } + + Ok(low) +} + +/// Return the largest credit-output amount the real asset-lock builder accepts. +/// +/// A drain build supplies a tight upper bound, then default-strategy probes find +/// the exact boundary without reproducing the selector's internal fee model. +fn asset_lock_max_amount_from_account_until( + managed_account: &ManagedCoreFundsAccount, + account: &Account, + current_height: u32, + deadline: &ProbeDeadline, +) -> Result { + let drain_ceiling = + match asset_lock_drain_ceiling(managed_account, account, current_height, deadline)? { + AssetLockDrainSeed::Ceiling(ceiling) => ceiling, + AssetLockDrainSeed::Unavailable => return Ok(0), + AssetLockDrainSeed::TooManyInputs { max } => { + return full_range_asset_lock_max_amount( + managed_account, + account, + current_height, + Some(max), + deadline, + ); + } + }; + asset_lock_max_below_upper_bound( + managed_account, + account, + current_height, + drain_ceiling, + deadline, + ) +} + +#[cfg(test)] +fn asset_lock_max_amount_from_account( + managed_account: &ManagedCoreFundsAccount, + account: &Account, + current_height: u32, +) -> Result { + asset_lock_max_amount_from_account_until( + managed_account, + account, + current_height, + &ProbeDeadline::unbounded(), + ) +} impl WalletBackend { + /// Query the largest asset-lock credit output accepted by the live wallet. + /// + /// This is the non-broadcasting counterpart of [`Self::create_asset_lock_proof`]. + /// It runs off the UI thread against the upstream wallet manager and never + /// exposes or reconstructs UTXO selection in the UI. + pub async fn asset_lock_max_amount( + &self, + seed_hash: &WalletSeedHash, + ) -> Result { + self.asset_lock_max_amount_with_timeout(seed_hash, ASSET_LOCK_PROBE_LOCK_HOLD_DEADLINE) + .await + } + + async fn asset_lock_max_amount_with_timeout( + &self, + seed_hash: &WalletSeedHash, + timeout: Duration, + ) -> Result { + let wallet = self.resolve_wallet(seed_hash).await?; + let wallet_id = wallet.wallet_id(); + // Every dry run performs one reservation read→reserve→release cycle. + // Keep them all under the same exclusive boundary as real builds. + let wallet_manager = Arc::clone(wallet.wallet_manager()).write_owned().await; + let (managed_account, account, current_height) = { + let (key_wallet, info) = wallet_manager + .get_wallet_and_info(&wallet_id) + .ok_or(TaskError::WalletStateInconsistent)?; + let account = key_wallet + .get_bip44_account(DEFAULT_BIP44_ACCOUNT) + .ok_or(TaskError::WalletStateInconsistent)? + .clone(); + let current_height = asset_lock_builder_height(&info.core_wallet); + let managed_account = info + .core_wallet + .accounts + .standard_bip44_accounts + .get(&DEFAULT_BIP44_ACCOUNT) + .ok_or(TaskError::WalletStateInconsistent)? + .clone(); + (managed_account, account, current_height) + }; + + tokio::task::spawn_blocking(move || { + // Started here — not on the async side — so blocking-pool queueing + // delay is not charged against the builder-work budget. + let deadline = ProbeDeadline::after(timeout); + let Some(observed_inputs) = asset_lock_final_input_state( + &managed_account, + &account, + current_height, + &deadline, + )? + else { + // The budget ran out before the composition was fully + // observed: return the provably-safe empty quote instead of + // continuing to hold the wallet-manager lock. The default + // (empty) key only matches a genuinely-empty composition, so + // validation stays fail-closed. + drop(wallet_manager); + return Ok(AssetLockMaxAmountQuote { + amount_duffs: 0, + observed_inputs: AssetLockInputState::default(), + is_partial: true, + }); + }; + let amount_duffs = asset_lock_max_amount_from_account_until( + &managed_account, + &account, + current_height, + &deadline, + )?; + let is_partial = deadline.timed_out(); + drop(wallet_manager); + Ok(AssetLockMaxAmountQuote { + amount_duffs, + observed_inputs, + is_partial, + }) + }) + .await? + .map_err(|source| TaskError::AssetLockBalanceQueryFailed { + source: Box::new(source), + }) + } + /// Derive the secp256k1 [`PrivateKey`](dash_sdk::dpp::dashcore::PrivateKey) at `path` from a held HD seed. /// Used after `create_asset_lock_proof` to obtain the one-time /// credit-output key needed to sign DET-retained non-identity state @@ -269,12 +870,25 @@ impl WalletBackend { #[cfg(test)] mod tests { + use super::{ + ASSET_LOCK_FEE_PER_KB, MAX_MONEY, asset_lock_builder_height, + asset_lock_max_amount_from_account, + }; use crate::model::fee_estimation::core_max_send_amount_duffs; + use crate::wallet_backend::snapshot::DetWalletBalance; + use dash_sdk::dpp::dashcore::ScriptBuf; + use dash_sdk::dpp::dashcore::blockdata::transaction::special_transaction::TransactionPayload; + use dash_sdk::dpp::dashcore::blockdata::transaction::special_transaction::asset_lock::AssetLockPayload; use dash_sdk::dpp::dashcore::hashes::Hash; use dash_sdk::dpp::dashcore::{Address, Network, OutPoint, PublicKey, TxOut, Txid}; use dash_sdk::dpp::key_wallet::Utxo; + use dash_sdk::dpp::key_wallet::wallet::Wallet; + use dash_sdk::dpp::key_wallet::wallet::initialization::WalletAccountCreationOptions; + 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::TransactionBuilder; + use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; /// Reproduces . /// The root cause is upstream in `dashpay/rust-dashcore` key-wallet's @@ -325,4 +939,451 @@ mod tests { assert_eq!(transaction.output.len(), 1); assert_eq!(fee, BALANCE_DUFFS - max_amount); } + + #[test] + fn asset_lock_max_excludes_unconfirmed_funds_counted_by_snapshot() { + const CONFIRMED_DUFFS: u64 = 1_000_000; + const UNCONFIRMED_DUFFS: u64 = 5_000_000; + const CURRENT_HEIGHT: u32 = 200; + + let wallet = Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default) + .expect("test wallet"); + let mut wallet_info = + ManagedWalletInfo::from_wallet_with_name(&wallet, "Test".to_string(), 0); + let account = wallet.get_bip44_account(0).expect("BIP44 account"); + let managed_account = wallet_info + .accounts + .standard_bip44_accounts + .get_mut(&0) + .expect("managed BIP44 account"); + let funding_address = managed_account + .next_receive_address(Some(&account.account_xpub), true) + .expect("funding address"); + + for (txid_byte, value, is_confirmed) in [ + (0x11, CONFIRMED_DUFFS, true), + (0x22, UNCONFIRMED_DUFFS, false), + ] { + let outpoint = OutPoint::new(Txid::from_byte_array([txid_byte; 32]), 0); + let mut utxo = Utxo::new( + outpoint, + TxOut { + value, + script_pubkey: funding_address.script_pubkey(), + }, + funding_address.clone(), + 100, + false, + ); + utxo.is_confirmed = is_confirmed; + managed_account.utxos.insert(outpoint, utxo); + } + + let snapshot_balance = DetWalletBalance { + confirmed: CONFIRMED_DUFFS, + unconfirmed: UNCONFIRMED_DUFFS, + total: CONFIRMED_DUFFS + UNCONFIRMED_DUFFS, + }; + let max_amount = + asset_lock_max_amount_from_account(managed_account, account, CURRENT_HEIGHT) + .expect("asset-lock maximum"); + + assert!( + max_amount > 0, + "the confirmed output must still provide a usable asset-lock Max" + ); + assert!( + max_amount < snapshot_balance.spendable(), + "Max must exclude the unconfirmed output that the asset-lock builder rejects" + ); + + let mut dry_run_account = managed_account.clone(); + let (transaction, _) = TransactionBuilder::new() + .set_fee_rate(FeeRate::new(ASSET_LOCK_FEE_PER_KB)) + .set_current_height(CURRENT_HEIGHT) + .set_special_payload(TransactionPayload::AssetLockPayloadType( + AssetLockPayload::new(vec![TxOut { + value: max_amount, + script_pubkey: ScriptBuf::new(), + }]), + )) + .set_funding(&mut dry_run_account, account) + .require_final_inputs() + .build_unsigned() + .expect("quoted Max must build through the real asset-lock selector"); + dry_run_account.release_reservation(&transaction); + + let mut one_over_account = managed_account.clone(); + let one_over = TransactionBuilder::new() + .set_fee_rate(FeeRate::new(ASSET_LOCK_FEE_PER_KB)) + .set_current_height(CURRENT_HEIGHT) + .set_special_payload(TransactionPayload::AssetLockPayloadType( + AssetLockPayload::new(vec![TxOut { + value: max_amount + 1, + script_pubkey: ScriptBuf::new(), + }]), + )) + .set_funding(&mut one_over_account, account) + .require_final_inputs() + .build_unsigned(); + assert!( + one_over.is_err(), + "one duff above the quoted Max must fail through the real selector" + ); + + let mut overshoot_account = managed_account.clone(); + let overshoot = TransactionBuilder::new() + .set_fee_rate(FeeRate::new(ASSET_LOCK_FEE_PER_KB)) + .set_current_height(CURRENT_HEIGHT) + .set_special_payload(TransactionPayload::AssetLockPayloadType( + AssetLockPayload::new(vec![TxOut { + value: snapshot_balance.spendable(), + script_pubkey: ScriptBuf::new(), + }]), + )) + .set_funding(&mut overshoot_account, account) + .require_final_inputs() + .build_unsigned(); + assert!( + overshoot.is_err(), + "the display-only snapshot amount must reproduce the builder rejection" + ); + } + + #[test] + fn asset_lock_max_uses_an_in_cap_subset_when_the_wallet_has_too_many_utxos() { + const CURRENT_HEIGHT: u32 = 200; + const INPUT_CAP: usize = 500; + const LARGE_UTXO_COUNT: usize = INPUT_CAP - 1; + const SMALL_UTXO_COUNT: usize = 17; + const UTXO_COUNT: usize = LARGE_UTXO_COUNT + SMALL_UTXO_COUNT; + const ASSET_LOCK_BASE_SIZE: usize = 115; + + // Match upstream exact-match sizing so the MAX+1 seed deterministically + // selects every UTXO and returns `TooManyInputs`. + let seed_probe_fee = FeeRate::new(ASSET_LOCK_FEE_PER_KB) + .calculate_fee(ASSET_LOCK_BASE_SIZE + 148 * (UTXO_COUNT + 1)); + let total_value = MAX_MONEY + 1 + seed_probe_fee; + let small_values: Vec = (0..SMALL_UTXO_COUNT) + .rev() + .map(|power| 1_u64 << power) + .collect(); + let large_total = total_value - small_values.iter().sum::(); + let base_utxo_duffs = large_total / LARGE_UTXO_COUNT as u64; + let larger_utxos = large_total % LARGE_UTXO_COUNT as u64; + + let wallet = Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default) + .expect("test wallet"); + let mut wallet_info = + ManagedWalletInfo::from_wallet_with_name(&wallet, "Test".to_string(), 0); + let account = wallet.get_bip44_account(0).expect("BIP44 account"); + let managed_account = wallet_info + .accounts + .standard_bip44_accounts + .get_mut(&0) + .expect("managed BIP44 account"); + let funding_address = managed_account + .next_receive_address(Some(&account.account_xpub), true) + .expect("funding address"); + + let values = (0..LARGE_UTXO_COUNT) + .map(|index| base_utxo_duffs + u64::from((index as u64) < larger_utxos)) + .chain(small_values); + for (index, value) in values.enumerate() { + let txid = Txid::from_byte_array([index as u8; 32]); + let outpoint = OutPoint::new(txid, index as u32); + let mut utxo = Utxo::new( + outpoint, + TxOut { + value, + script_pubkey: funding_address.script_pubkey(), + }, + funding_address.clone(), + 100, + false, + ); + utxo.is_confirmed = true; + managed_account.utxos.insert(outpoint, utxo); + } + + let max_amount = + asset_lock_max_amount_from_account(managed_account, account, CURRENT_HEIGHT) + .expect("asset-lock maximum"); + assert!( + max_amount > base_utxo_duffs, + "Max must use a real in-cap subset instead of collapsing to zero" + ); + + let mut dry_run_account = managed_account.clone(); + let (transaction, _) = TransactionBuilder::new() + .set_fee_rate(FeeRate::new(ASSET_LOCK_FEE_PER_KB)) + .set_current_height(CURRENT_HEIGHT) + .set_special_payload(TransactionPayload::AssetLockPayloadType( + AssetLockPayload::new(vec![TxOut { + value: max_amount, + script_pubkey: ScriptBuf::new(), + }]), + )) + .set_funding(&mut dry_run_account, account) + .require_final_inputs() + .build_unsigned() + .expect("quoted Max must build from an in-cap subset"); + assert!( + transaction.input.len() <= INPUT_CAP, + "the achievable quote must respect the builder's input cap" + ); + dry_run_account.release_reservation(&transaction); + + let mut one_over_account = managed_account.clone(); + let one_over = TransactionBuilder::new() + .set_fee_rate(FeeRate::new(ASSET_LOCK_FEE_PER_KB)) + .set_current_height(CURRENT_HEIGHT) + .set_special_payload(TransactionPayload::AssetLockPayloadType( + AssetLockPayload::new(vec![TxOut { + value: max_amount + 1, + script_pubkey: ScriptBuf::new(), + }]), + )) + .set_funding(&mut one_over_account, account) + .require_final_inputs() + .build_unsigned(); + assert!( + one_over.is_err(), + "one duff above the quote must exceed the achievable in-cap subset" + ); + } + + #[test] + fn asset_lock_max_uses_last_processed_height_when_sync_watermarks_diverge() { + let wallet = Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default) + .expect("test wallet"); + let mut wallet_info = + ManagedWalletInfo::from_wallet_with_name(&wallet, "Test".to_string(), 0); + wallet_info.update_last_processed_height(200); + wallet_info.update_synced_height(300); + + assert_eq!(wallet_info.last_processed_height(), 200); + assert_eq!(wallet_info.synced_height(), 300); + assert_eq!( + asset_lock_builder_height(&wallet_info), + wallet_info.last_processed_height(), + "the Max probe must use the same block-processed watermark as the real asset-lock builder" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn asset_lock_max_probe_waits_for_wallet_manager_write_lock() { + use crate::app::TaskResult; + use crate::context::test_support::test_app_context; + use crate::utils::egui_mpsc::SenderAsync; + use std::sync::Arc; + use std::time::Duration; + + let temp_dir = tempfile::tempdir().expect("temp dir"); + let app_context = test_app_context(temp_dir.path()); + let (sender, _receiver) = tokio::sync::mpsc::channel::(16); + app_context + .ensure_wallet_backend(SenderAsync::new(sender, app_context.egui_ctx().clone())) + .await + .expect("wallet backend"); + let backend = app_context.wallet_backend().expect("wired backend"); + + let seed = [0x41; 64]; + let wallet = crate::model::wallet::Wallet::new_from_seed( + seed, + Network::Testnet, + Some("Probe lock test".to_string()), + None, + ) + .expect("DET wallet"); + let seed_hash = wallet.seed_hash(); + backend + .register_wallet_from_seed(&seed_hash, &seed, None) + .await + .expect("register wallet"); + + let platform_wallet = backend + .resolve_wallet(&seed_hash) + .await + .expect("platform wallet"); + let read_guard = platform_wallet.wallet_manager().read().await; + let probe_backend = Arc::clone(&backend); + let mut probe = + tokio::spawn(async move { probe_backend.asset_lock_max_amount(&seed_hash).await }); + + assert!( + tokio::time::timeout(Duration::from_millis(250), &mut probe) + .await + .is_err(), + "the probe must wait for exclusive wallet-manager access" + ); + + drop(read_guard); + let amount = tokio::time::timeout(Duration::from_secs(5), probe) + .await + .expect("probe completes after read guard drops") + .expect("probe task") + .expect("asset-lock maximum"); + assert_eq!( + amount.amount_duffs, 0, + "an empty wallet has no asset-lock maximum" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn asset_lock_max_probe_deadline_bounds_the_wallet_manager_write_lock() { + use crate::app::TaskResult; + use crate::context::test_support::test_app_context; + use crate::utils::egui_mpsc::SenderAsync; + use std::time::Duration; + + let temp_dir = tempfile::tempdir().expect("temp dir"); + let app_context = test_app_context(temp_dir.path()); + let (sender, _receiver) = tokio::sync::mpsc::channel::(16); + app_context + .ensure_wallet_backend(SenderAsync::new(sender, app_context.egui_ctx().clone())) + .await + .expect("wallet backend"); + let backend = app_context.wallet_backend().expect("wired backend"); + + let seed = [0x42; 64]; + let wallet = crate::model::wallet::Wallet::new_from_seed( + seed, + Network::Testnet, + Some("Probe timeout test".to_string()), + None, + ) + .expect("DET wallet"); + let seed_hash = wallet.seed_hash(); + backend + .register_wallet_from_seed(&seed_hash, &seed, None) + .await + .expect("register wallet"); + + let quote = backend + .asset_lock_max_amount_with_timeout(&seed_hash, Duration::ZERO) + .await + .expect("deadline returns a conservative quote"); + assert!( + quote.is_partial, + "an expired search must be tagged as partial" + ); + + let platform_wallet = backend + .resolve_wallet(&seed_hash) + .await + .expect("platform wallet"); + let _write_guard = tokio::time::timeout( + Duration::from_secs(1), + platform_wallet.wallet_manager().write(), + ) + .await + .expect("the expired probe must release its exclusive lock"); + } + + /// Companion to the search-phase deadline test above: the UTXO-composition + /// observation that runs before the search must obey the same budget, so a + /// wallet with many eligible UTXOs cannot hold the process-global + /// wallet-manager write lock through unbounded builder batches. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn asset_lock_probe_deadline_bounds_the_observation_phase() { + use crate::app::TaskResult; + use crate::context::test_support::test_app_context; + use crate::utils::egui_mpsc::SenderAsync; + use crate::wallet_backend::AssetLockInputState; + use std::time::Duration; + + // Two observation batches' worth of eligible UTXOs. + const UTXO_COUNT: u32 = 501; + + let temp_dir = tempfile::tempdir().expect("temp dir"); + let app_context = test_app_context(temp_dir.path()); + let (sender, _receiver) = tokio::sync::mpsc::channel::(16); + app_context + .ensure_wallet_backend(SenderAsync::new(sender, app_context.egui_ctx().clone())) + .await + .expect("wallet backend"); + let backend = app_context.wallet_backend().expect("wired backend"); + + let seed = [0x44; 64]; + let wallet = crate::model::wallet::Wallet::new_from_seed( + seed, + Network::Testnet, + Some("Observation deadline test".to_string()), + None, + ) + .expect("DET wallet"); + let seed_hash = wallet.seed_hash(); + backend + .register_wallet_from_seed(&seed_hash, &seed, None) + .await + .expect("register wallet"); + let platform_wallet = backend + .resolve_wallet(&seed_hash) + .await + .expect("platform wallet"); + let wallet_id = platform_wallet.wallet_id(); + + { + let mut manager = platform_wallet.wallet_manager().write().await; + let account = manager + .get_wallet_and_info(&wallet_id) + .and_then(|(key_wallet, _)| key_wallet.get_bip44_account(0)) + .expect("BIP44 account") + .clone(); + let info = manager + .get_wallet_info_mut(&wallet_id) + .expect("wallet info"); + info.core_wallet.update_last_processed_height(200); + let managed_account = info + .core_wallet + .accounts + .standard_bip44_accounts + .get_mut(&0) + .expect("managed account"); + let funding_address = managed_account + .next_receive_address(Some(&account.account_xpub), true) + .expect("funding address"); + for index in 0..UTXO_COUNT { + let outpoint = OutPoint::new(Txid::from_byte_array([index as u8; 32]), index); + let mut utxo = Utxo::new( + outpoint, + TxOut { + value: 100_000, + script_pubkey: funding_address.script_pubkey(), + }, + funding_address.clone(), + 100, + false, + ); + utxo.is_confirmed = true; + managed_account.utxos.insert(outpoint, utxo); + } + } + + let quote = backend + .asset_lock_max_amount_with_timeout(&seed_hash, Duration::ZERO) + .await + .expect("an exhausted budget still returns a conservative quote"); + assert!( + quote.is_partial, + "an observation stopped by the deadline must be tagged as partial" + ); + assert_eq!( + quote.amount_duffs, 0, + "no amount was proven buildable within the budget" + ); + assert_eq!( + quote.observed_inputs, + AssetLockInputState::default(), + "an expired budget must abort the observation instead of running builder batches" + ); + + let _write_guard = tokio::time::timeout( + Duration::from_secs(1), + platform_wallet.wallet_manager().write(), + ) + .await + .expect("the expired observation must release its exclusive lock"); + } } diff --git a/src/wallet_backend/snapshot.rs b/src/wallet_backend/snapshot.rs index 3ea0af0ba..9d940d61e 100644 --- a/src/wallet_backend/snapshot.rs +++ b/src/wallet_backend/snapshot.rs @@ -35,15 +35,29 @@ use std::sync::Arc; use std::sync::Mutex; use arc_swap::ArcSwap; +use dash_sdk::dpp::dashcore::blockdata::transaction::special_transaction::TransactionPayload; +use dash_sdk::dpp::dashcore::blockdata::transaction::special_transaction::asset_lock::AssetLockPayload; use dash_sdk::dpp::dashcore::{Address, OutPoint, ScriptBuf, Transaction, TxOut, Txid}; +#[cfg(test)] +use dash_sdk::dpp::key_wallet::Utxo; +use dash_sdk::dpp::key_wallet::account::Account; use dash_sdk::dpp::key_wallet::bip32::DerivationPath; +use dash_sdk::dpp::key_wallet::managed_account::ManagedCoreFundsAccount; use dash_sdk::dpp::key_wallet::managed_account::transaction_record::{ OutputRole, TransactionRecord, }; use dash_sdk::dpp::key_wallet::transaction_checking::TransactionContext; +use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::coin_selection::{ + SelectionError, SelectionStrategy, +}; +use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::fee::FeeRate; +use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::transaction_builder::{ + BuilderError, TransactionBuilder, +}; use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use platform_wallet::PlatformWallet; +use super::payments::ProbeDeadline; use crate::backend_task::error::TaskError; use crate::model::dashpay::DetectedIncomingOutput; use crate::model::wallet::{TransactionStatus, WalletSeedHash, WalletTransaction}; @@ -52,13 +66,44 @@ use crate::model::wallet::{TransactionStatus, WalletSeedHash, WalletTransaction} /// DET's `WalletSeedHash`. Mirrors the alias in [`super`]. type WalletId = [u8; 32]; +/// Mirrors upstream's private `MAX_STANDARD_TX_INPUTS` (key-wallet +/// `transaction_builder.rs`). If upstream ever lowers its cap below this, every +/// full observation batch fails fatally with `TooManyInputs` — re-align then. +const ASSET_LOCK_INPUT_OBSERVATION_BATCH_SIZE: usize = 500; +static ASSET_LOCK_INPUT_OBSERVATION_LOCK: Mutex<()> = Mutex::new(()); + +/// Exact final, unreserved input composition observed by an asset-lock builder. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AssetLockInputState { + /// Sum of the observed input values in duffs. + pub final_funds_duffs: u64, + inputs: Arc<[(OutPoint, u64)]>, +} + +impl AssetLockInputState { + pub(crate) fn from_inputs( + inputs: impl IntoIterator, + ) -> AssetLockInputState { + let mut inputs: Vec<_> = inputs.into_iter().collect(); + inputs.sort_unstable(); + let final_funds_duffs = inputs + .iter() + .fold(0_u64, |total, (_, value)| total.saturating_add(*value)); + Self { + final_funds_duffs, + inputs: inputs.into(), + } + } +} + /// Confirmed / unconfirmed / total balance in duffs. DET-shaped — no upstream /// `WalletBalance` / `WalletCoreBalance` crosses the seam /// (rust-best-practices M-DONT-LEAK-TYPES). /// /// `total` is the headline figure and counts immature coinbase and locked -/// (CoinJoin) funds that coin selection cannot touch. `spendable()` is the -/// subset the upstream `CoinSelector` actually draws from. +/// (CoinJoin) funds. `spendable()` is a display subtotal only; operation- +/// specific finality rules and live reservations can make the builder accept +/// less. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct DetWalletBalance { pub confirmed: u64, @@ -67,11 +112,10 @@ pub struct DetWalletBalance { } impl DetWalletBalance { - /// Funds coin selection can spend right now: confirmed plus unconfirmed. - /// Excludes the immature and locked duffs that `total` counts but the - /// upstream `CoinSelector` rejects. Reserve a "Max" send against this, not - /// `total`, or the send over-shoots the selectable set and fails with - /// insufficient funds. + /// Display subtotal of confirmed plus unconfirmed funds. + /// + /// This is not a coin-selection guarantee. Max and validation must query + /// the backend builder used by their operation. pub fn spendable(&self) -> u64 { self.confirmed.saturating_add(self.unconfirmed) } @@ -89,6 +133,12 @@ pub struct DetUtxo { /// Per-wallet display snapshot. Cheap to clone-share via the enclosing `Arc`. #[derive(Debug, Clone, Default)] pub struct WalletSnapshot { + /// Monotonic per-wallet publish counter used to invalidate derived UI caches. + pub(super) generation: u64, + /// Exact final, unreserved input composition seen by the live builder. + pub(super) asset_lock_inputs: AssetLockInputState, + /// Revision advanced whenever that exact input composition changes. + pub(super) asset_lock_input_revision: u64, pub balance: DetWalletBalance, pub transactions: Vec, pub utxos: Vec, @@ -160,12 +210,139 @@ impl TransactionHistoryStatus { /// argument limit and makes the carry-forward-on-contention path explicit. struct SnapshotState { balance: DetWalletBalance, + asset_lock_inputs: AssetLockInputState, utxos: Vec, address_balances: BTreeMap, monitored_receive_addresses: Vec, address_paths: BTreeMap, } +#[cfg(test)] +fn asset_lock_final_inputs<'a>( + utxos: impl IntoIterator, + current_height: u32, +) -> AssetLockInputState { + AssetLockInputState::from_inputs(utxos.into_iter().filter_map(|utxo| { + ((utxo.is_confirmed || utxo.is_instantlocked) && utxo.is_spendable(current_height)) + .then_some((utxo.outpoint, utxo.value())) + })) +} + +/// Sum inputs eligible for an asset-lock builder requiring final inputs. +#[cfg(test)] +fn asset_lock_final_funds_duffs<'a>( + utxos: impl IntoIterator, + current_height: u32, +) -> u64 { + asset_lock_final_inputs(utxos, current_height).final_funds_duffs +} + +fn observe_asset_lock_inputs_locked( + managed_account: &ManagedCoreFundsAccount, + account: &Account, + current_height: u32, + deadline: &ProbeDeadline, +) -> Result, BuilderError> { + let candidates: Vec<_> = managed_account + .utxos + .values() + .filter(|utxo| { + (utxo.is_confirmed || utxo.is_instantlocked) && utxo.is_spendable(current_height) + }) + .cloned() + .collect(); + let values: BTreeMap<_, _> = candidates + .iter() + .map(|utxo| (utxo.outpoint, utxo.value())) + .collect(); + let mut observed = Vec::with_capacity(candidates.len()); + // One account clone reused across batches: only `.utxos` varies per batch, + // and the live `ReservationSet` is `Arc`-shared by the clone anyway. + let mut dry_run_account = managed_account.clone(); + + for batch in candidates.chunks(ASSET_LOCK_INPUT_OBSERVATION_BATCH_SIZE) { + if deadline.expired() { + return Ok(None); + } + dry_run_account.utxos = batch + .iter() + .cloned() + .map(|utxo| (utxo.outpoint, utxo)) + .collect(); + let result = TransactionBuilder::new() + .set_fee_rate(FeeRate::new(0)) + .set_current_height(current_height) + .set_selection_strategy(SelectionStrategy::All) + .set_special_payload(TransactionPayload::AssetLockPayloadType( + AssetLockPayload::new(vec![TxOut { + value: 1, + script_pubkey: ScriptBuf::new(), + }]), + )) + .set_funding(&mut dry_run_account, account) + .require_final_inputs() + .build_unsigned(); + match result { + Ok((transaction, _)) => { + observed.extend(transaction.input.iter().filter_map(|input| { + values + .get(&input.previous_output) + .map(|value| (input.previous_output, *value)) + })); + dry_run_account.release_reservation(&transaction); + } + Err(BuilderError::CoinSelection(SelectionError::NoUtxosAvailable)) => {} + Err(BuilderError::CoinSelection(SelectionError::InsufficientFunds { .. })) + | Err(BuilderError::InsufficientFunds { .. }) => { + // A sub-dust batch is an ordinary near-drained state, not a + // failure; its eligible candidates still shape the real + // builder's cross-batch choices, so they stay in the key. + // Unlike the success branch above, this arm cannot filter out + // an already-reserved outpoint, so one may transiently enter + // `observed`. That is safe: this key only ever drives quote + // *match* detection (see the doc comment below), never the + // amount `TransactionBuilder` reports or actually spends. + observed.extend(batch.iter().map(|utxo| (utxo.outpoint, utxo.value()))); + } + Err(source) => return Err(source), + } + } + + Ok(Some(AssetLockInputState::from_inputs(observed))) +} + +/// Observe the final input composition under the global observation lock. +/// This is a match key for quote-staleness detection, not a spendable-funds +/// set — it may transiently include a reserved outpoint (see the sub-dust +/// batch arm above), which only affects key matching and is fail-closed. +/// `Ok(None)` means the deadline expired before the whole composition was +/// observed — a truncated set must never be used as a key. +pub(super) fn asset_lock_final_input_state( + managed_account: &ManagedCoreFundsAccount, + account: &Account, + current_height: u32, + deadline: &ProbeDeadline, +) -> Result, BuilderError> { + let _observation_guard = ASSET_LOCK_INPUT_OBSERVATION_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + observe_asset_lock_inputs_locked(managed_account, account, current_height, deadline) +} + +/// Like [`asset_lock_final_input_state`], but `Ok(None)` also covers losing +/// the observation try-lock, so the per-event recompute path never blocks. +fn try_asset_lock_final_input_state( + managed_account: &ManagedCoreFundsAccount, + account: &Account, + current_height: u32, + deadline: &ProbeDeadline, +) -> Result, BuilderError> { + let Ok(_observation_guard) = ASSET_LOCK_INPUT_OBSERVATION_LOCK.try_lock() else { + return Ok(None); + }; + observe_asset_lock_inputs_locked(managed_account, account, current_height, deadline) +} + /// Map a finalized-or-pending upstream `TransactionContext` to DET's richer /// `TransactionStatus`. Upstream now distinguishes InstantSend and chain-lock, /// so this supersedes the old height-only `from_height` heuristic. @@ -378,6 +555,7 @@ fn address_paths_from_info( fn carried_forward_state(prior: &WalletSnapshot) -> SnapshotState { SnapshotState { balance: prior.balance, + asset_lock_inputs: prior.asset_lock_inputs.clone(), utxos: prior.utxos.clone(), address_balances: prior.address_balances.clone(), monitored_receive_addresses: prior.monitored_receive_addresses.clone(), @@ -397,6 +575,9 @@ fn carried_forward_state(prior: &WalletSnapshot) -> SnapshotState { pub(super) struct SnapshotStore { /// DET-keyed published snapshots. Lock-free read on the UI hot path. snapshots: ArcSwap>>, + /// Per-wallet publish counters and eligible-input revisions, advanced + /// together before each RCU publication. + generations: Mutex>, /// Event-sourced transaction history, keyed by upstream `WalletId` then /// `Txid`. A `BTreeMap` per wallet so re-seen records (mempool → block → /// chainlock) upsert in place and iteration is deterministic. @@ -414,6 +595,13 @@ struct RegisteredWallet { wallet: Arc, } +#[derive(Default)] +struct SnapshotRevision { + generation: u64, + asset_lock_input_revision: u64, + asset_lock_inputs: Option, +} + impl std::fmt::Debug for SnapshotStore { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("SnapshotStore").finish_non_exhaustive() @@ -424,6 +612,7 @@ impl SnapshotStore { pub(super) fn new() -> Self { Self { snapshots: ArcSwap::from_pointee(HashMap::new()), + generations: Mutex::new(HashMap::new()), tx_log: Mutex::new(HashMap::new()), transaction_history_status: Mutex::new(HashMap::new()), registered: Mutex::new(HashMap::new()), @@ -454,6 +643,9 @@ impl SnapshotStore { next.remove(seed_hash); next }); + if let Ok(mut generations) = self.generations.lock() { + generations.remove(seed_hash); + } if let Ok(mut map) = self.registered.lock() { map.remove(wallet_id); } @@ -487,6 +679,19 @@ impl SnapshotStore { .unwrap_or_default() } + /// Atomically read the generation, exact input composition, and revision. + pub(super) fn asset_lock_probe_snapshot( + &self, + seed_hash: &WalletSeedHash, + ) -> (u64, AssetLockInputState, u64) { + let snapshot = self.snapshot(seed_hash); + ( + snapshot.generation, + snapshot.asset_lock_inputs.clone(), + snapshot.asset_lock_input_revision, + ) + } + /// Whether a snapshot has been published for the wallet yet. `false` /// before the first `EventBridge` recompute ⇒ the UI shows "syncing". pub(super) fn has_snapshot(&self, seed_hash: &WalletSeedHash) -> bool { @@ -590,7 +795,8 @@ impl SnapshotStore { /// Never blocks and never awaits: `try_state()` is a non-blocking try-lock /// that yields `None` under contention, in which case the *entire* prior /// snapshot is carried forward and a subsequent event recomputes once the - /// lock is free. + /// lock is free. Contention on the asset-lock observation alone carries + /// forward only the composition field — never the fresh balance/UTXOs. /// /// # Balance / breakdown consistency /// @@ -629,11 +835,52 @@ impl SnapshotStore { let state = match wallet.try_state() { Some(state) => { let core_balance = state.balance(); + let current_height = state.last_processed_height(); let balance = DetWalletBalance { confirmed: core_balance.confirmed(), unconfirmed: core_balance.unconfirmed(), total: core_balance.total(), }; + // Best-effort: on observation contention or failure only this + // field carries forward — the composition key is fail-closed + // by construction, so a stale key merely forces revalidation. + let asset_lock_inputs = match ( + state + .wallet() + .get_bip44_account(super::DEFAULT_BIP44_ACCOUNT), + state + .core_wallet + .accounts + .standard_bip44_accounts + .get(&super::DEFAULT_BIP44_ACCOUNT), + ) { + (Some(account), Some(managed_account)) => { + match try_asset_lock_final_input_state( + managed_account, + account, + current_height, + &ProbeDeadline::unbounded(), + ) { + Ok(Some(inputs)) => inputs, + Ok(None) => { + tracing::debug!( + wallet = ?&wallet_id[..4], + "asset-lock observation contended during snapshot recompute; carrying the prior composition forward" + ); + self.snapshot(&seed_hash).asset_lock_inputs.clone() + } + Err(source) => { + tracing::debug!( + wallet = ?&wallet_id[..4], + ?source, + "asset-lock observation failed during snapshot recompute; carrying the prior composition forward" + ); + self.snapshot(&seed_hash).asset_lock_inputs.clone() + } + } + } + _ => AssetLockInputState::default(), + }; let mut utxos = Vec::new(); let mut address_balances: BTreeMap = BTreeMap::new(); for u in state.utxos() { @@ -647,6 +894,7 @@ impl SnapshotStore { } SnapshotState { balance, + asset_lock_inputs, utxos, address_balances, monitored_receive_addresses: external_addresses_from_info(&state.core_wallet), @@ -679,7 +927,24 @@ impl SnapshotStore { .and_then(|log| log.get(wallet_id).map(|m| m.values().cloned().collect())) .unwrap_or_default(); + let (generation, asset_lock_input_revision) = { + let mut generations = match self.generations.lock() { + Ok(generations) => generations, + Err(poisoned) => poisoned.into_inner(), + }; + let revision = generations.entry(*seed_hash).or_default(); + revision.generation = revision.generation.saturating_add(1); + if revision.asset_lock_inputs.as_ref() != Some(&state.asset_lock_inputs) { + revision.asset_lock_input_revision = + revision.asset_lock_input_revision.saturating_add(1); + revision.asset_lock_inputs = Some(state.asset_lock_inputs.clone()); + } + (revision.generation, revision.asset_lock_input_revision) + }; let snapshot = Arc::new(WalletSnapshot { + generation, + asset_lock_inputs: state.asset_lock_inputs, + asset_lock_input_revision, balance: state.balance, transactions, utxos: state.utxos, @@ -696,7 +961,12 @@ impl SnapshotStore { self.snapshots.rcu(|current| { let mut next = (**current).clone(); - next.insert(*seed_hash, snapshot.clone()); + if current + .get(seed_hash) + .is_none_or(|prior| prior.generation < snapshot.generation) + { + next.insert(*seed_hash, Arc::clone(&snapshot)); + } next }); } @@ -759,6 +1029,7 @@ mod tests { &wid, SnapshotState { balance: DetWalletBalance::default(), + asset_lock_inputs: AssetLockInputState::default(), utxos: Vec::new(), address_balances: BTreeMap::new(), monitored_receive_addresses: Vec::new(), @@ -771,6 +1042,8 @@ mod tests { fn empty_store_yields_default_snapshot() { let store = SnapshotStore::new(); let snap = store.snapshot(&seed(1)); + assert_eq!(snap.generation, 0); + assert_eq!(snap.asset_lock_inputs.final_funds_duffs, 0); assert_eq!(snap.balance, DetWalletBalance::default()); assert!(snap.transactions.is_empty()); assert!(snap.utxos.is_empty()); @@ -778,6 +1051,106 @@ mod tests { assert!(snap.monitored_receive_addresses.is_empty()); } + #[test] + fn wallet_snapshot_generation_advances_on_each_publish() { + let store = SnapshotStore::new(); + + publish_tx_only(&store, seed(2), wid(2)); + assert_eq!(store.snapshot(&seed(2)).generation, 1); + + publish_tx_only(&store, seed(2), wid(2)); + assert_eq!(store.snapshot(&seed(2)).generation, 2); + } + + #[test] + fn asset_lock_probe_snapshot_reads_generation_and_final_funds_together() { + let store = SnapshotStore::new(); + + store.publish( + &seed(3), + &wid(3), + SnapshotState { + balance: DetWalletBalance { + confirmed: 0, + unconfirmed: 1_000, + total: 1_000, + }, + asset_lock_inputs: AssetLockInputState::default(), + utxos: Vec::new(), + address_balances: BTreeMap::new(), + monitored_receive_addresses: Vec::new(), + address_paths: BTreeMap::new(), + }, + ); + assert_eq!( + store.asset_lock_probe_snapshot(&seed(3)), + (1, AssetLockInputState::default(), 1) + ); + + store.publish( + &seed(3), + &wid(3), + SnapshotState { + balance: DetWalletBalance { + confirmed: 1_000, + unconfirmed: 0, + total: 1_000, + }, + asset_lock_inputs: AssetLockInputState::from_inputs([( + OutPoint::new(Txid::from_byte_array([1; 32]), 0), + 1_000, + )]), + utxos: Vec::new(), + address_balances: BTreeMap::new(), + monitored_receive_addresses: Vec::new(), + address_paths: BTreeMap::new(), + }, + ); + assert_eq!( + store.asset_lock_probe_snapshot(&seed(3)), + ( + 2, + AssetLockInputState::from_inputs([( + OutPoint::new(Txid::from_byte_array([1; 32]), 0), + 1_000, + )]), + 2, + ) + ); + + store.publish( + &seed(3), + &wid(3), + SnapshotState { + balance: DetWalletBalance { + confirmed: 1_000, + unconfirmed: 0, + total: 1_000, + }, + asset_lock_inputs: AssetLockInputState::from_inputs([( + OutPoint::new(Txid::from_byte_array([2; 32]), 0), + 1_000, + )]), + utxos: Vec::new(), + address_balances: BTreeMap::new(), + monitored_receive_addresses: Vec::new(), + address_paths: BTreeMap::new(), + }, + ); + assert_eq!( + store.asset_lock_probe_snapshot(&seed(3)), + ( + 3, + AssetLockInputState::from_inputs([( + OutPoint::new(Txid::from_byte_array([2; 32]), 0), + 1_000, + )]), + 3, + ), + "equal final funds with different inputs must advance the input revision" + ); + } + /// FUNDS-SAFETY (display list): the Receive list is sourced from the /// snapshot's `monitored_receive_addresses` — the SPV-watched set published /// off the event-bridge recompute. Publishing a watched set makes it the @@ -794,6 +1167,7 @@ mod tests { &wid(9), SnapshotState { balance: DetWalletBalance::default(), + asset_lock_inputs: AssetLockInputState::default(), utxos: Vec::new(), address_balances: BTreeMap::new(), monitored_receive_addresses: watched.clone(), @@ -900,6 +1274,420 @@ mod tests { assert!(balance.spendable() < balance.total); } + #[test] + fn asset_lock_final_funds_tracks_confirmation_and_instant_lock() { + let address = addr(13); + let mut utxo = Utxo::new( + OutPoint::null(), + TxOut { + value: 1_000, + script_pubkey: address.script_pubkey(), + }, + address, + 100, + false, + ); + + assert_eq!(asset_lock_final_funds_duffs([&utxo], 200), 0); + + utxo.is_instantlocked = true; + assert_eq!(asset_lock_final_funds_duffs([&utxo], 200), 1_000); + + utxo.is_instantlocked = false; + utxo.is_confirmed = true; + assert_eq!(asset_lock_final_funds_duffs([&utxo], 200), 1_000); + + utxo.is_locked = true; + assert_eq!(asset_lock_final_funds_duffs([&utxo], 200), 0); + } + + #[test] + fn asset_lock_input_fingerprint_is_independent_of_utxo_order() { + let address = addr(14); + let mut first = Utxo::new( + OutPoint::new(Txid::from_byte_array([0x41; 32]), 0), + TxOut { + value: 1_000, + script_pubkey: address.script_pubkey(), + }, + address.clone(), + 100, + false, + ); + first.is_confirmed = true; + let mut second = Utxo::new( + OutPoint::new(Txid::from_byte_array([0x42; 32]), 1), + TxOut { + value: 2_000, + script_pubkey: address.script_pubkey(), + }, + address, + 100, + false, + ); + second.is_confirmed = true; + + let forward = asset_lock_final_inputs([&first, &second], 200); + let reversed = asset_lock_final_inputs([&second, &first], 200); + + assert_eq!(forward.final_funds_duffs, 3_000); + assert_eq!( + forward, reversed, + "the same eligible set must compare equally regardless of input order" + ); + + let mut third = Utxo::new( + OutPoint::new(Txid::from_byte_array([0x43; 32]), 2), + TxOut { + value: 3_000, + script_pubkey: addr(14).script_pubkey(), + }, + addr(14), + 100, + false, + ); + third.is_confirmed = true; + let different_set = asset_lock_final_inputs([&first, &third], 200); + assert_ne!( + forward, different_set, + "a genuinely different eligible set must not compare equal" + ); + } + + #[test] + fn asset_lock_input_revision_changes_when_an_eligible_utxo_is_reserved() { + use dash_sdk::dpp::dashcore::blockdata::transaction::special_transaction::TransactionPayload; + use dash_sdk::dpp::dashcore::blockdata::transaction::special_transaction::asset_lock::AssetLockPayload; + use dash_sdk::dpp::key_wallet::wallet::Wallet as UpstreamWallet; + use dash_sdk::dpp::key_wallet::wallet::initialization::WalletAccountCreationOptions; + 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::TransactionBuilder; + + const CURRENT_HEIGHT: u32 = 200; + let wallet = UpstreamWallet::from_seed_bytes( + [0x51; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .expect("upstream wallet"); + let account = wallet.get_bip44_account(0).expect("BIP44 account"); + let mut info = ManagedWalletInfo::from_wallet(&wallet, CURRENT_HEIGHT); + let managed_account = info + .accounts + .standard_bip44_accounts + .get_mut(&0) + .expect("managed account"); + let funding_address = managed_account + .next_receive_address(Some(&account.account_xpub), true) + .expect("funding address"); + let outpoint = OutPoint::new(Txid::from_byte_array([0x52; 32]), 0); + let mut utxo = Utxo::new( + outpoint, + TxOut { + value: 1_000_000, + script_pubkey: funding_address.script_pubkey(), + }, + funding_address, + 100, + false, + ); + utxo.is_confirmed = true; + managed_account.utxos.insert(outpoint, utxo); + + let before = asset_lock_final_input_state( + managed_account, + account, + CURRENT_HEIGHT, + &ProbeDeadline::unbounded(), + ) + .expect("initial input observation") + .expect("unbounded observation completes"); + let store = SnapshotStore::new(); + store.publish( + &seed(0x51), + &wid(0x51), + SnapshotState { + balance: DetWalletBalance::default(), + asset_lock_inputs: before, + utxos: Vec::new(), + address_balances: BTreeMap::new(), + monitored_receive_addresses: Vec::new(), + address_paths: BTreeMap::new(), + }, + ); + let initial_revision = store.snapshot(&seed(0x51)).asset_lock_input_revision; + + let (reserved_tx, _) = TransactionBuilder::new() + .set_fee_rate(FeeRate::new(1_000)) + .set_current_height(CURRENT_HEIGHT) + .set_selection_strategy(SelectionStrategy::All) + .set_special_payload(TransactionPayload::AssetLockPayloadType( + AssetLockPayload::new(vec![TxOut { + value: 1, + script_pubkey: ScriptBuf::new(), + }]), + )) + .set_funding(managed_account, account) + .require_final_inputs() + .build_unsigned() + .expect("reservation-producing build"); + + let after = asset_lock_final_input_state( + managed_account, + account, + CURRENT_HEIGHT, + &ProbeDeadline::unbounded(), + ) + .expect("reservation-aware input observation") + .expect("unbounded observation completes"); + assert_eq!(after.final_funds_duffs, 0); + store.publish( + &seed(0x51), + &wid(0x51), + SnapshotState { + balance: DetWalletBalance::default(), + asset_lock_inputs: after, + utxos: Vec::new(), + address_balances: BTreeMap::new(), + monitored_receive_addresses: Vec::new(), + address_paths: BTreeMap::new(), + }, + ); + assert!( + store.snapshot(&seed(0x51)).asset_lock_input_revision > initial_revision, + "taking a live reservation must advance the display-side input revision" + ); + managed_account.release_reservation(&reserved_tx); + } + + #[test] + fn asset_lock_observation_reports_incomplete_when_the_deadline_has_expired() { + use dash_sdk::dpp::key_wallet::wallet::Wallet as UpstreamWallet; + use dash_sdk::dpp::key_wallet::wallet::initialization::WalletAccountCreationOptions; + use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use std::time::Duration; + + const CURRENT_HEIGHT: u32 = 200; + let wallet = UpstreamWallet::from_seed_bytes( + [0x53; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .expect("upstream wallet"); + let account = wallet.get_bip44_account(0).expect("BIP44 account"); + let mut info = ManagedWalletInfo::from_wallet(&wallet, CURRENT_HEIGHT); + let managed_account = info + .accounts + .standard_bip44_accounts + .get_mut(&0) + .expect("managed account"); + let funding_address = managed_account + .next_receive_address(Some(&account.account_xpub), true) + .expect("funding address"); + let outpoint = OutPoint::new(Txid::from_byte_array([0x54; 32]), 0); + let mut utxo = Utxo::new( + outpoint, + TxOut { + value: 1_000_000, + script_pubkey: funding_address.script_pubkey(), + }, + funding_address, + 100, + false, + ); + utxo.is_confirmed = true; + managed_account.utxos.insert(outpoint, utxo); + + let expired = ProbeDeadline::after(Duration::ZERO); + assert_eq!( + asset_lock_final_input_state(managed_account, account, CURRENT_HEIGHT, &expired) + .expect("observation"), + None, + "an expired deadline must stop the observation before any builder batch runs" + ); + + let complete = asset_lock_final_input_state( + managed_account, + account, + CURRENT_HEIGHT, + &ProbeDeadline::unbounded(), + ) + .expect("observation") + .expect("an unbounded observation must complete"); + assert_eq!(complete.final_funds_duffs, 1_000_000); + } + + #[test] + fn asset_lock_observation_treats_a_sub_dust_balance_as_observed_not_failed() { + use dash_sdk::dpp::key_wallet::wallet::Wallet as UpstreamWallet; + use dash_sdk::dpp::key_wallet::wallet::initialization::WalletAccountCreationOptions; + use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + + const CURRENT_HEIGHT: u32 = 200; + // At or below the 546-duff network dust threshold the drain selection + // reports insufficient funds — an everyday near-drained wallet state. + const SUB_DUST_DUFFS: u64 = 300; + + let wallet = UpstreamWallet::from_seed_bytes( + [0x55; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .expect("upstream wallet"); + let account = wallet.get_bip44_account(0).expect("BIP44 account"); + let mut info = ManagedWalletInfo::from_wallet(&wallet, CURRENT_HEIGHT); + let managed_account = info + .accounts + .standard_bip44_accounts + .get_mut(&0) + .expect("managed account"); + let funding_address = managed_account + .next_receive_address(Some(&account.account_xpub), true) + .expect("funding address"); + let outpoint = OutPoint::new(Txid::from_byte_array([0x56; 32]), 0); + let mut utxo = Utxo::new( + outpoint, + TxOut { + value: SUB_DUST_DUFFS, + script_pubkey: funding_address.script_pubkey(), + }, + funding_address, + 100, + false, + ); + utxo.is_confirmed = true; + managed_account.utxos.insert(outpoint, utxo); + + let observed = asset_lock_final_input_state( + managed_account, + account, + CURRENT_HEIGHT, + &ProbeDeadline::unbounded(), + ) + .expect("a sub-dust balance is an ordinary wallet state, not an observation failure") + .expect("an unbounded observation must complete"); + + assert_eq!( + observed.final_funds_duffs, SUB_DUST_DUFFS, + "the eligible sub-dust input must stay in the composition key" + ); + assert_ne!( + observed, + AssetLockInputState::default(), + "a sub-dust composition must stay distinct from the fail-closed empty marker" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn contended_asset_lock_observation_keeps_the_fresh_balance_and_utxos() { + use crate::app::TaskResult; + use crate::context::test_support::test_app_context; + use crate::utils::egui_mpsc::SenderAsync; + use std::time::Duration; + + let temp_dir = tempfile::tempdir().expect("temp dir"); + let app_context = test_app_context(temp_dir.path()); + let (sender, _receiver) = tokio::sync::mpsc::channel::(16); + app_context + .ensure_wallet_backend(SenderAsync::new(sender, app_context.egui_ctx().clone())) + .await + .expect("wallet backend"); + let backend = app_context.wallet_backend().expect("wired backend"); + + let seed = [0x46; 64]; + let det_wallet = crate::model::wallet::Wallet::new_from_seed( + seed, + Network::Testnet, + Some("Observation contention test".to_string()), + None, + ) + .expect("DET wallet"); + let seed_hash = det_wallet.seed_hash(); + backend + .register_wallet_from_seed(&seed_hash, &seed, None) + .await + .expect("register wallet"); + let platform_wallet = backend + .resolve_wallet(&seed_hash) + .await + .expect("platform wallet"); + let wallet_id = platform_wallet.wallet_id(); + + let outpoint = OutPoint::new(Txid::from_byte_array([0x47; 32]), 0); + { + let mut manager = platform_wallet.wallet_manager().write().await; + let account = manager + .get_wallet_and_info(&wallet_id) + .and_then(|(key_wallet, _)| key_wallet.get_bip44_account(0)) + .expect("BIP44 account") + .clone(); + let info = manager + .get_wallet_info_mut(&wallet_id) + .expect("wallet info"); + info.core_wallet.update_last_processed_height(200); + let managed_account = info + .core_wallet + .accounts + .standard_bip44_accounts + .get_mut(&0) + .expect("managed account"); + let funding_address = managed_account + .next_receive_address(Some(&account.account_xpub), true) + .expect("funding address"); + let mut utxo = Utxo::new( + outpoint, + TxOut { + value: 1_000_000, + script_pubkey: funding_address.script_pubkey(), + }, + funding_address, + 100, + false, + ); + utxo.is_confirmed = true; + managed_account.utxos.insert(outpoint, utxo); + info.core_wallet.update_balance(); + } + + let store = SnapshotStore::new(); + store.register_wallet(seed_hash, wallet_id, Arc::clone(&platform_wallet)); + + { + let _observation_guard = ASSET_LOCK_INPUT_OBSERVATION_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + store.recompute(&wallet_id); + } + let contended = store.snapshot(&seed_hash); + assert!( + contended.utxos.iter().any(|utxo| utxo.outpoint == outpoint), + "an asset-lock observation miss must not discard the freshly computed UTXO set" + ); + assert_eq!( + contended.asset_lock_inputs, + AssetLockInputState::default(), + "only the asset-lock composition may carry forward when its observation is contended" + ); + + // The backend's own event bridge shares the global observation lock, + // so an uncontended recompute may need a few attempts. + let mut observed = store.snapshot(&seed_hash); + for _ in 0..50 { + if observed.asset_lock_inputs.final_funds_duffs > 0 { + break; + } + store.recompute(&wallet_id); + observed = store.snapshot(&seed_hash); + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!( + observed.asset_lock_inputs.final_funds_duffs, 1_000_000, + "an uncontended recompute must observe the eligible composition" + ); + } + /// Crosses the `send_screen` "Max" seam: the Max a Core send reserves must /// come from the *spendable* set, never `total`. When the wallet holds /// immature/locked funds (total > spendable), feeding `total` to the Max @@ -1106,6 +1894,12 @@ mod tests { address_paths.insert(a.clone(), DerivationPath::from(Vec::new())); address_paths.insert(b.clone(), DerivationPath::from(Vec::new())); WalletSnapshot { + generation: 1, + asset_lock_inputs: AssetLockInputState::from_inputs([( + OutPoint::new(Txid::from_byte_array([1; 32]), 0), + 6_000, + )]), + asset_lock_input_revision: 1, balance: DetWalletBalance { confirmed: 6_000, unconfirmed: 0, @@ -1429,6 +2223,10 @@ mod tests { unconfirmed: 0, total, }, + asset_lock_inputs: AssetLockInputState::from_inputs([( + OutPoint::new(Txid::from_byte_array([1; 32]), 0), + total, + )]), utxos: Vec::new(), address_balances: address_balances.clone(), monitored_receive_addresses: Vec::new(), diff --git a/tests/kittest/send_screen.rs b/tests/kittest/send_screen.rs index c9698ea73..ef445fe72 100644 --- a/tests/kittest/send_screen.rs +++ b/tests/kittest/send_screen.rs @@ -7,7 +7,7 @@ use dash_evo_tool::model::address::{ValidatedAddress, encode_shielded_address}; use dash_evo_tool::model::amount::Amount; use dash_evo_tool::model::qualified_identity::encrypted_key_storage::KeyStorage; use dash_evo_tool::model::qualified_identity::{IdentityStatus, IdentityType, QualifiedIdentity}; -use dash_evo_tool::model::wallet::Wallet; +use dash_evo_tool::model::wallet::{Wallet, WalletSeedHash}; use dash_evo_tool::ui::wallets::send_screen::{SourceSelection, WalletSendScreen}; use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; @@ -48,7 +48,7 @@ fn shielded_address() -> String { .expect("shielded address") } -fn send_screen() -> (tokio::runtime::Runtime, WalletSendScreen) { +fn send_screen() -> (tokio::runtime::Runtime, WalletSendScreen, WalletSeedHash) { let (runtime, app_context) = fresh_app_context(); let wallet = Wallet::new_from_seed( rand::random(), @@ -57,9 +57,11 @@ fn send_screen() -> (tokio::runtime::Runtime, WalletSendScreen) { None, ) .expect("wallet fixture"); + let seed_hash = wallet.seed_hash(); ( runtime, WalletSendScreen::new(&app_context, Arc::new(RwLock::new(wallet))), + seed_hash, ) } @@ -102,9 +104,13 @@ fn assert_route( source: SourceSelection, destination: ValidatedAddress, button_label: &str, + asset_lock_max_amount: Option, ) -> Result { with_isolated_data_dir(|| { - let (_runtime, mut screen) = send_screen(); + let (_runtime, mut screen, seed_hash) = send_screen(); + if let Some(amount_duffs) = asset_lock_max_amount { + screen.seed_asset_lock_max_amount_for_test(seed_hash, amount_duffs); + } screen.set_simple_send_input_for_test( Some(source), Some(destination), @@ -134,7 +140,7 @@ fn assert_validation_error( expected: &str, ) { with_isolated_data_dir(|| { - let (_runtime, mut screen) = send_screen(); + let (_runtime, mut screen, _) = send_screen(); screen.set_simple_send_input_for_test(source, destination, amount); let mut harness = Harness::builder().build_ui_state( @@ -161,6 +167,7 @@ fn routes_core_wallet_to_core_address() { SourceSelection::CoreWallet, ValidatedAddress::Core(core_address()), "Send DASH", + None, ) .expect_err("offline Core wallet has no spendable snapshot balance"); assert!(error.starts_with("Insufficient balance. Need "), "{error}"); @@ -177,10 +184,17 @@ fn routes_core_wallet_to_platform_address() { bech32m: address.to_bech32m_string(Network::Testnet), }, "Fund Platform Address", + Some(0), ) .expect_err("offline Core wallet has no spendable snapshot balance"); - assert!(error.starts_with("Insufficient balance. Need "), "{error}"); - assert!(error.contains("including fee"), "{error}"); + assert!( + error.starts_with("You can transfer up to 0 DASH right now."), + "{error}" + ); + assert!( + error.contains("Choose a smaller amount or wait for more funds."), + "{error}" + ); } #[test] @@ -194,6 +208,7 @@ fn routes_platform_addresses_to_platform_address() { bech32m: address.to_bech32m_string(Network::Testnet), }, "Transfer Credits", + None, ) .expect("Platform transfer task"); let AppAction::BackendTask(BackendTask::WalletTask(WalletTask::TransferPlatformCredits { @@ -215,6 +230,7 @@ fn routes_platform_addresses_to_core_address() { SourceSelection::PlatformAddresses(source), ValidatedAddress::Core(destination), "Withdraw to Wallet", + None, ) .expect("Platform withdrawal task"); let AppAction::BackendTask(BackendTask::WalletTask(WalletTask::WithdrawFromPlatformAddress { @@ -238,6 +254,7 @@ fn routes_shielded_pool_to_shielded_address() { SourceSelection::Shielded(seed_hash, 3 * ONE_DASH_CREDITS), ValidatedAddress::Shielded(destination), "Private Send", + None, ) .expect("shielded transfer task"); let AppAction::BackendTask(BackendTask::ShieldedTask(ShieldedTask::ShieldedTransfer { @@ -264,6 +281,7 @@ fn routes_shielded_pool_to_platform_address() { bech32m: address.to_bech32m_string(Network::Testnet), }, "Unshield Credits", + None, ) .expect("unshield task"); let AppAction::BackendTask(BackendTask::ShieldedTask(ShieldedTask::UnshieldCredits {